@blamejs/blamejs-shop 0.1.29 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.
12
+
11
13
  - v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
12
14
 
13
15
  - v0.1.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.
package/README.md CHANGED
@@ -64,6 +64,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
65
  | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
+ | **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
67
68
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
68
69
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
69
70
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
@@ -78,7 +79,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
78
79
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
79
80
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
80
81
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
81
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
82
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
82
83
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
83
84
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
84
85
 
@@ -111,6 +112,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
111
112
  - `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
112
113
  - `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
113
114
  - `migrations-d1/0201_category_navigation.sql` — hierarchical category tree (self-referential parent_slug, sibling position, soft-delete tombstone)
115
+ - `migrations-d1/0133_product_qa.sql` — product Q&A questions + answers + distinct-session vote-ups (moderation FSM, hash-only author identity, FK CASCADE)
114
116
 
115
117
  ### Demo seed
116
118
 
package/SECURITY.md CHANGED
@@ -148,6 +148,15 @@ node -e "
148
148
  publishes them through `/admin/reviews`. The author identity is
149
149
  stored hash-only (`b.crypto.namespaceHash`) — the raw email is never
150
150
  persisted.
151
+ - **Product Q&A is operator-moderated user-generated content.** A
152
+ customer question is stored `pending` and never reaches the product
153
+ page until an operator approves it through `/admin/questions`;
154
+ customer-submitted answers are not accepted from the storefront at
155
+ all — only the operator posts the authoritative answer, and it too
156
+ lands `pending` until approved. So neither an unmoderated question nor
157
+ an unmoderated reply can ever surface publicly. Author identity is the
158
+ customer id (verified against the customers primitive) or a hash-only
159
+ email — the raw address is never persisted.
151
160
  - **Gift-card codes are bearer secrets, stored hash-only.** A gift
152
161
  card's plaintext code is shown exactly once at issuance and never
153
162
  persisted — only its `namespaceHash` digest plus a 4-character hint
package/lib/admin.js CHANGED
@@ -244,13 +244,14 @@ function mount(router, deps) {
244
244
  var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
245
245
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
246
246
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
247
+ var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
247
248
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
248
249
  var customers = deps.customers || null; // read-only customers console disabled when absent
249
250
 
250
251
  // Which optional console sections are wired — gates their nav links so a
251
252
  // signed-in admin is never sent to a route that wasn't mounted. Passed
252
253
  // into every authed render call as `nav_available`.
253
- var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
254
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
254
255
 
255
256
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
256
257
 
@@ -986,6 +987,196 @@ function mount(router, deps) {
986
987
  ));
987
988
  }
988
989
 
990
+ // ---- product Q&A (moderation) ---------------------------------------
991
+
992
+ // Operator-side Q&A moderation. The queue lists questions across all
993
+ // products in one status (defaults to `pending`); the per-question
994
+ // detail page shows the thread and lets the operator approve / reject
995
+ // the question, post the authoritative answer, approve / reject /
996
+ // pin individual answers. The storefront leaves new questions in
997
+ // `pending`; operator-posted answers land `pending` and are approved
998
+ // from the same page. Endpoints are omitted entirely when no productQa
999
+ // primitive is wired.
1000
+ if (productQa) {
1001
+ // A guardUuid / shape rejection is a TypeError; a not-found row and
1002
+ // a refused FSM transition carry a code. Map all to client errors so
1003
+ // a bad id / stale action is a notice, never a 500.
1004
+ function _qaClientError(e) {
1005
+ if (!e) return null;
1006
+ if (e instanceof TypeError) return { status: 400, slug: "bad-request" };
1007
+ if (e.code === "PRODUCT_QA_QUESTION_NOT_FOUND") return { status: 404, slug: "question-not-found" };
1008
+ if (e.code === "PRODUCT_QA_ANSWER_NOT_FOUND") return { status: 404, slug: "answer-not-found" };
1009
+ if (e.code === "PRODUCT_QA_TRANSITION_REFUSED") return { status: 409, slug: "transition-refused" };
1010
+ if (e.code === "PRODUCT_QA_PIN_REFUSED") return { status: 409, slug: "pin-refused" };
1011
+ return null;
1012
+ }
1013
+
1014
+ router.get("/admin/questions", _pageOrApi(true,
1015
+ R(async function (req, res) {
1016
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1017
+ var status = (url && url.searchParams.get("status")) || "pending";
1018
+ var limitS = url && url.searchParams.get("limit");
1019
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
1020
+ var rows = await productQa.listQuestionsByStatus(status, { limit: limit });
1021
+ _json(res, 200, { rows: rows });
1022
+ }),
1023
+ async function (req, res) {
1024
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1025
+ var status = (url && url.searchParams.get("status")) || "pending";
1026
+ var notice = null, rows = [];
1027
+ // A bad ?status= raises a TypeError — fall back to pending.
1028
+ try {
1029
+ rows = await productQa.listQuestionsByStatus(status, { limit: 100 });
1030
+ } catch (e) {
1031
+ if (!(e instanceof TypeError)) throw e;
1032
+ status = "pending"; notice = "Unknown status filter — showing pending questions.";
1033
+ rows = await productQa.listQuestionsByStatus("pending", { limit: 100 });
1034
+ }
1035
+ _sendHtml(res, 200, renderAdminQuestions({
1036
+ shop_name: deps.shop_name, nav_available: navAvailable,
1037
+ questions: rows, status: status, notice: notice,
1038
+ moved: url && url.searchParams.get("moved"),
1039
+ }));
1040
+ },
1041
+ ));
1042
+
1043
+ router.get("/admin/questions/:id", _pageOrApi(true,
1044
+ R(async function (req, res) {
1045
+ var q;
1046
+ try { q = await productQa.getQuestion(req.params.id); }
1047
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "question-not-found"); throw e; }
1048
+ if (!q) return _problem(res, 404, "question-not-found");
1049
+ q.answers = await productQa.listAnswersForQuestion(q.id, { limit: 200 });
1050
+ _json(res, 200, q);
1051
+ }),
1052
+ async function (req, res) {
1053
+ var q;
1054
+ try { q = await productQa.getQuestion(req.params.id); }
1055
+ catch (e) { if (!(e instanceof TypeError)) throw e; q = null; }
1056
+ if (!q) return _sendHtml(res, 404, renderAdminQuestions({
1057
+ shop_name: deps.shop_name, nav_available: navAvailable,
1058
+ questions: [], status: "pending", notice: "Question not found.",
1059
+ }));
1060
+ // Show every answer regardless of status so the operator can
1061
+ // moderate pending/rejected answers, not just approved ones.
1062
+ var answers = await productQa.listAnswersForQuestion(q.id, { limit: 200 });
1063
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1064
+ _sendHtml(res, 200, renderAdminQuestion({
1065
+ shop_name: deps.shop_name, nav_available: navAvailable,
1066
+ question: q, answers: answers,
1067
+ moved: url && url.searchParams.get("moved"),
1068
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed." : null,
1069
+ }));
1070
+ },
1071
+ ));
1072
+
1073
+ // A Q&A action: run `opFn(id, body)`, then PRG. A bad id / shape /
1074
+ // FSM refusal becomes a notice on the queue (question-level) or the
1075
+ // detail (answer-level), never a 500; anything else propagates.
1076
+ function _qaQuestionAction(jsonHandler, auditEvent, opFn) {
1077
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
1078
+ var id = req.params.id;
1079
+ try { await opFn(id, req.body || {}); }
1080
+ catch (e) {
1081
+ if (_qaClientError(e)) return _redirect(res, "/admin/questions?err=1");
1082
+ throw e;
1083
+ }
1084
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
1085
+ _redirect(res, "/admin/questions?moved=1");
1086
+ });
1087
+ }
1088
+ function _qaAnswerAction(jsonHandler, auditEvent, opFn, backFor) {
1089
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
1090
+ var id = req.params.id;
1091
+ var back = backFor(req);
1092
+ try { await opFn(id, req.body || {}); }
1093
+ catch (e) {
1094
+ if (_qaClientError(e)) return _redirect(res, "/admin/questions/" + encodeURIComponent(back) + "?err=1");
1095
+ throw e;
1096
+ }
1097
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
1098
+ _redirect(res, "/admin/questions/" + encodeURIComponent(back) + "?moved=1");
1099
+ });
1100
+ }
1101
+
1102
+ router.post("/admin/questions/:id/approve", _qaQuestionAction(
1103
+ W("question.approve", async function (req, res) {
1104
+ var q;
1105
+ try { q = await productQa.approveQuestion(req.params.id); }
1106
+ catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1107
+ _json(res, 200, q); return q;
1108
+ }),
1109
+ "question.approve",
1110
+ function (id) { return productQa.approveQuestion(id); },
1111
+ ));
1112
+
1113
+ router.post("/admin/questions/:id/reject", _qaQuestionAction(
1114
+ W("question.reject", async function (req, res) {
1115
+ var body = req.body || {};
1116
+ var q;
1117
+ try { q = await productQa.rejectQuestion(req.params.id, body.reason); }
1118
+ catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1119
+ _json(res, 200, q); return q;
1120
+ }),
1121
+ "question.reject",
1122
+ function (id, body) { return productQa.rejectQuestion(id, body.reason || undefined); },
1123
+ ));
1124
+
1125
+ // Post the operator's authoritative answer to a question. The answer
1126
+ // lands `pending`; the operator approves it from the same detail page
1127
+ // (so the post + publish stay explicit, mirroring the question FSM).
1128
+ router.post("/admin/questions/:id/answer", _qaAnswerAction(
1129
+ W("question.answer", async function (req, res) {
1130
+ var body = req.body || {};
1131
+ var a;
1132
+ try {
1133
+ a = await productQa.submitAnswer({ question_id: req.params.id, author: "operator", body: body.body });
1134
+ } catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1135
+ _json(res, 201, a); return a;
1136
+ }),
1137
+ "question.answer",
1138
+ function (id, body) { return productQa.submitAnswer({ question_id: id, author: "operator", body: body.body }); },
1139
+ function (req) { return req.params.id; },
1140
+ ));
1141
+
1142
+ router.post("/admin/answers/:id/approve", _qaAnswerAction(
1143
+ W("answer.approve", async function (req, res) {
1144
+ var a;
1145
+ try { a = await productQa.approveAnswer(req.params.id); }
1146
+ catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1147
+ _json(res, 200, a); return a;
1148
+ }),
1149
+ "answer.approve",
1150
+ function (id) { return productQa.approveAnswer(id); },
1151
+ function (req) { return (req.body || {}).question_id || ""; },
1152
+ ));
1153
+
1154
+ router.post("/admin/answers/:id/reject", _qaAnswerAction(
1155
+ W("answer.reject", async function (req, res) {
1156
+ var body = req.body || {};
1157
+ var a;
1158
+ try { a = await productQa.rejectAnswer(req.params.id, body.reason); }
1159
+ catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1160
+ _json(res, 200, a); return a;
1161
+ }),
1162
+ "answer.reject",
1163
+ function (id, body) { return productQa.rejectAnswer(id, body.reason || undefined); },
1164
+ function (req) { return (req.body || {}).question_id || ""; },
1165
+ ));
1166
+
1167
+ router.post("/admin/answers/:id/pin", _qaAnswerAction(
1168
+ W("answer.pin", async function (req, res) {
1169
+ var a;
1170
+ try { a = await productQa.pinAnswer(req.params.id); }
1171
+ catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
1172
+ _json(res, 200, a); return a;
1173
+ }),
1174
+ "answer.pin",
1175
+ function (id) { return productQa.pinAnswer(id); },
1176
+ function (req) { return (req.body || {}).question_id || ""; },
1177
+ ));
1178
+ }
1179
+
989
1180
  // ---- returns (moderation) -------------------------------------------
990
1181
 
991
1182
  // Operator-side RMA moderation. The queue lists return
@@ -2442,6 +2633,7 @@ var ADMIN_NAV_ITEMS = [
2442
2633
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
2443
2634
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
2444
2635
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
2636
+ { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
2445
2637
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
2446
2638
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
2447
2639
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
@@ -3006,6 +3198,128 @@ function renderAdminReviews(opts) {
3006
3198
  return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
3007
3199
  }
3008
3200
 
3201
+ // The Q&A moderation states an operator can filter the question queue by.
3202
+ var QA_STATUS_FILTERS = ["pending", "approved", "rejected"];
3203
+
3204
+ function _qaStatusPill(status) {
3205
+ var cls = status === "approved" ? "paid" : status === "rejected" ? "cancelled" : "pending";
3206
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
3207
+ }
3208
+
3209
+ // Cross-product question queue. Each card links to the per-question
3210
+ // detail where the operator posts + publishes the answer. Inline
3211
+ // approve / reject act on the question itself.
3212
+ function renderAdminQuestions(opts) {
3213
+ opts = opts || {};
3214
+ var list = opts.questions || [];
3215
+ var active = opts.status || "pending";
3216
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Question updated.</div>" : "";
3217
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
3218
+
3219
+ var chips = "<div class=\"order-filters\">" +
3220
+ QA_STATUS_FILTERS.map(function (s) {
3221
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/questions?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
3222
+ }).join("") +
3223
+ "</div>";
3224
+
3225
+ var cards = list.map(function (q) {
3226
+ var detail = "/admin/questions/" + _htmlEscape(q.id);
3227
+ var pub = "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/approve\" class=\"form-inline\">" +
3228
+ "<button class=\"btn\" type=\"submit\">Approve</button></form>";
3229
+ var rej = "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/reject\" class=\"review-reject\">" +
3230
+ "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
3231
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>";
3232
+ // Rejected questions are terminal for the approve path (the lib
3233
+ // refuses approve-from-rejected), so only show reject from approved
3234
+ // and both from pending.
3235
+ var actions = q.status === "approved" ? rej
3236
+ : q.status === "rejected" ? ""
3237
+ : pub + " " + rej;
3238
+ return "<div class=\"panel review-card\">" +
3239
+ "<div class=\"review-card__head\">" +
3240
+ _qaStatusPill(q.status) + " " +
3241
+ "<a href=\"" + detail + "\"><strong>Open thread</strong></a>" +
3242
+ "</div>" +
3243
+ "<p class=\"review-card__body\">" + _htmlEscape(q.body || "") + "</p>" +
3244
+ "<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(q.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(q.occurred_at)) + "</p>" +
3245
+ (actions ? "<div class=\"order-actions\">" + actions + "</div>" : "") +
3246
+ "</div>";
3247
+ }).join("");
3248
+
3249
+ var queue = list.length ? cards : "<p class=\"empty\">No “" + _htmlEscape(active) + "” questions.</p>";
3250
+ var body = "<section><h2>Q&amp;A</h2>" + moved + notice + chips + queue + "</section>";
3251
+ return _renderAdminShell(opts.shop_name, "Q&A", body, "questions", opts.nav_available);
3252
+ }
3253
+
3254
+ // Per-question detail: the question, its answer thread (every status),
3255
+ // the operator answer form, and per-answer approve / reject / pin.
3256
+ function renderAdminQuestion(opts) {
3257
+ opts = opts || {};
3258
+ var q = opts.question || {};
3259
+ var answers = opts.answers || [];
3260
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Updated.</div>" : "";
3261
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
3262
+
3263
+ var qApprove = q.status === "pending"
3264
+ ? "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/approve\" class=\"form-inline\"><button class=\"btn\" type=\"submit\">Approve question</button></form>"
3265
+ : "";
3266
+ var qReject = q.status !== "rejected"
3267
+ ? "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/reject\" class=\"review-reject\">" +
3268
+ "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
3269
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject question</button></form>"
3270
+ : "";
3271
+
3272
+ var qPanel = "<div class=\"panel review-card\">" +
3273
+ "<div class=\"review-card__head\">" + _qaStatusPill(q.status) + "</div>" +
3274
+ "<p class=\"review-card__body\">" + _htmlEscape(q.body || "") + "</p>" +
3275
+ "<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(q.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(q.occurred_at)) + "</p>" +
3276
+ ((qApprove || qReject) ? "<div class=\"order-actions\">" + qApprove + " " + qReject + "</div>" : "") +
3277
+ "</div>";
3278
+
3279
+ var answerCards = answers.map(function (a) {
3280
+ var hid = "<input type=\"hidden\" name=\"question_id\" value=\"" + _htmlEscape(q.id) + "\">";
3281
+ var approve = a.status === "pending"
3282
+ ? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/approve\" class=\"form-inline\">" + hid + "<button class=\"btn\" type=\"submit\">Approve</button></form>"
3283
+ : "";
3284
+ var reject = a.status !== "rejected"
3285
+ ? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/reject\" class=\"review-reject\">" + hid +
3286
+ "<input type=\"text\" name=\"reason\" placeholder=\"Reason\" maxlength=\"300\" required>" +
3287
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>"
3288
+ : "";
3289
+ // Pin is only valid for an approved operator answer; the lib refuses
3290
+ // otherwise, so only surface the control when it'll succeed.
3291
+ var pin = (Number(a.is_operator) === 1 && a.status === "approved" && Number(a.pinned) !== 1)
3292
+ ? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/pin\" class=\"form-inline\">" + hid + "<button class=\"btn\" type=\"submit\">Pin as top</button></form>"
3293
+ : "";
3294
+ var who = Number(a.is_operator) === 1 ? "Seller" : (a.author === "system" ? "System" : "Customer");
3295
+ return "<div class=\"panel review-card\">" +
3296
+ "<div class=\"review-card__head\">" +
3297
+ _qaStatusPill(a.status) + " <strong>" + _htmlEscape(who) + "</strong> " +
3298
+ (Number(a.pinned) === 1 ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
3299
+ "</div>" +
3300
+ "<p class=\"review-card__body\">" + _htmlEscape(a.body || "") + "</p>" +
3301
+ "<div class=\"order-actions\">" + approve + " " + reject + " " + pin + "</div>" +
3302
+ "</div>";
3303
+ }).join("");
3304
+ var thread = answers.length ? answerCards : "<p class=\"empty\">No answers yet.</p>";
3305
+
3306
+ var answerForm = "<div class=\"panel\"><h3>Post the seller answer</h3>" +
3307
+ "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/answer\">" +
3308
+ "<textarea name=\"body\" maxlength=\"4000\" rows=\"4\" required></textarea>" +
3309
+ "<div class=\"order-actions\"><button class=\"btn\" type=\"submit\">Submit answer</button></div>" +
3310
+ "</form>" +
3311
+ "<p class=\"meta\">The answer lands pending — approve it above to publish it on the product page.</p>" +
3312
+ "</div>";
3313
+
3314
+ var body = "<section><h2>Question</h2>" +
3315
+ "<p class=\"meta\"><a href=\"/admin/questions\">← Back to queue</a></p>" +
3316
+ moved + notice + qPanel +
3317
+ "<h3 class=\"mt\">Answers</h3>" + thread + answerForm +
3318
+ "</section>";
3319
+ return _renderAdminShell(opts.shop_name, "Question", body, "questions", opts.nav_available);
3320
+ }
3321
+
3322
+
3009
3323
  function renderAdminInventory(opts) {
3010
3324
  opts = opts || {};
3011
3325
  var rows = opts.inventory || [];
@@ -3573,6 +3887,8 @@ module.exports = {
3573
3887
  renderAdminReturns: renderAdminReturns,
3574
3888
  renderAdminReturn: renderAdminReturn,
3575
3889
  renderAdminReviews: renderAdminReviews,
3890
+ renderAdminQuestions: renderAdminQuestions,
3891
+ renderAdminQuestion: renderAdminQuestion,
3576
3892
  renderAdminCollections: renderAdminCollections,
3577
3893
  renderAdminCollection: renderAdminCollection,
3578
3894
  renderAdminGiftCards: renderAdminGiftCards,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.29",
2
+ "version": "0.1.30",
3
3
  "assets": {
4
4
  "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
5
  "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
package/lib/product-qa.js CHANGED
@@ -631,6 +631,90 @@ function create(opts) {
631
631
  return r.rows.length ? _decodeAnswer(r.rows[0]) : null;
632
632
  }
633
633
 
634
+ // Every approved answer under a question, pinned answer first, then
635
+ // by vote_count then oldest-first. Drives the PDP thread (the
636
+ // question + its full answer list) where topAnswerForQuestion only
637
+ // surfaces the single best one. Pending / rejected answers never
638
+ // surface here.
639
+ async function answersForQuestion(questionId, listOpts) {
640
+ _uuid(questionId, "question_id");
641
+ listOpts = listOpts || {};
642
+ var limit = _limit(listOpts.limit);
643
+ var r = await query(
644
+ "SELECT * FROM product_qa_answers " +
645
+ "WHERE question_id = ?1 AND status = 'approved' " +
646
+ "ORDER BY pinned DESC, vote_count DESC, occurred_at ASC, id ASC " +
647
+ "LIMIT ?2",
648
+ [questionId, limit],
649
+ );
650
+ var rows = [];
651
+ for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
652
+ return rows;
653
+ }
654
+
655
+ // Every answer under a question regardless of status — the operator
656
+ // moderation detail needs the pending / rejected answers too, which
657
+ // answersForQuestion (approved-only, storefront-facing) hides. Pinned
658
+ // first, then newest-first so the queue reads top-down.
659
+ async function listAnswersForQuestion(questionId, listOpts) {
660
+ _uuid(questionId, "question_id");
661
+ listOpts = listOpts || {};
662
+ var limit = _limit(listOpts.limit);
663
+ var r = await query(
664
+ "SELECT * FROM product_qa_answers WHERE question_id = ?1 " +
665
+ "ORDER BY pinned DESC, occurred_at DESC, id DESC LIMIT ?2",
666
+ [questionId, limit],
667
+ );
668
+ var rows = [];
669
+ for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
670
+ return rows;
671
+ }
672
+
673
+ // Cross-product moderation queue — every question in one status
674
+ // regardless of product. The operator console needs all `pending`
675
+ // questions globally, which questionsForProduct (per-product) can't
676
+ // serve. status is required (the queue is always status-scoped).
677
+ // Newest-first by the same (occurred_at DESC, id DESC) total order
678
+ // questionsForProduct uses.
679
+ async function listQuestionsByStatus(status, listOpts) {
680
+ var s = _statusFilter(status);
681
+ if (s === undefined) {
682
+ throw new TypeError("productQA.listQuestionsByStatus: status is required (one of " + STATUSES.join(", ") + ")");
683
+ }
684
+ listOpts = listOpts || {};
685
+ var limit = _limit(listOpts.limit);
686
+ var r = await query(
687
+ "SELECT * FROM product_qa_questions WHERE status = ?1 " +
688
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?2",
689
+ [s, limit],
690
+ );
691
+ var rows = [];
692
+ for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeQuestion(r.rows[i]));
693
+ return rows;
694
+ }
695
+
696
+ // Cross-product answer moderation queue — every answer in one status
697
+ // regardless of question. The operator console moderates customer-
698
+ // submitted answers (operator-authored answers land approved by the
699
+ // post-answer path, so the pending queue is the customer backlog).
700
+ // Newest-first.
701
+ async function listAnswersByStatus(status, listOpts) {
702
+ var s = _statusFilter(status);
703
+ if (s === undefined) {
704
+ throw new TypeError("productQA.listAnswersByStatus: status is required (one of " + STATUSES.join(", ") + ")");
705
+ }
706
+ listOpts = listOpts || {};
707
+ var limit = _limit(listOpts.limit);
708
+ var r = await query(
709
+ "SELECT * FROM product_qa_answers WHERE status = ?1 " +
710
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?2",
711
+ [s, limit],
712
+ );
713
+ var rows = [];
714
+ for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
715
+ return rows;
716
+ }
717
+
634
718
  // Per-product rollup used by the PDP header ("12 questions, 8
635
719
  // answered, average 3 upvotes per answer"). Counts approved rows
636
720
  // only — pending / rejected don't surface to storefront copy.
@@ -723,6 +807,10 @@ function create(opts) {
723
807
  questionsForProduct: questionsForProduct,
724
808
  getQuestion: getQuestion,
725
809
  topAnswerForQuestion: topAnswerForQuestion,
810
+ answersForQuestion: answersForQuestion,
811
+ listAnswersForQuestion: listAnswersForQuestion,
812
+ listQuestionsByStatus: listQuestionsByStatus,
813
+ listAnswersByStatus: listAnswersByStatus,
726
814
  metricsForProduct: metricsForProduct,
727
815
  cleanupRejected: cleanupRejected,
728
816
  };