Files
hermes-config/skills/self-hosting/shop-pro-quote/references/quote-sharing-approval-architecture.md
T
2026-07-12 10:17:17 -04:00

6.4 KiB

Quote Sharing & Customer Approval Architecture

Overview

The quote → share → customer approve/decline → shop notified flow. Covers v2 React/Vite frontend only.

Key Files

File Role
src/components/quoteGenerator/QuoteSummary.tsx "Share with Customer" button + email/SMS draft links
src/pages/QuoteApprove.tsx Public customer-facing approval page (no auth)
src/store/quote.ts Zustand store with approvedSubtotal(), decision state
src/lib/contactLinks.ts openEmailDraft() and openSmsDraft() — local mailto:/sms: links only
src/lib/totals.ts Billable subtotal logic: approved-only vs all-non-declined
src/pages/Dashboard.tsx Real-time subscription on quotes collection (line 162)
src/schemas/quote.ts Zod validation — shareToken, status, service customerDecision

Share Flow (QuoteSummary.tsx:104-170)

  1. ensureShareToken() called — saves quote to PocketBase if not yet saved, generates a crypto.randomUUID() token, writes it to quote.shareToken
  2. Returns URL: ${window.location.origin}/quote/approve/${token}
  3. Copy to clipboard: handleShare() (line 154) — copies link, shows shareUrl UI with Email/SMS buttons
  4. Email draft: handleSendEmail() (line 172) — calls openEmailDraft(to="customerPhone", subject, body) which fires mailto: link. Does NOT send from server.
  5. SMS draft: handleSendSms() (line 195) — calls openSmsDraft(to="customerPhone", body) which fires sms: link. Does NOT send from server.
  6. Quote status set to 'sent' on first share.

Important: customerEmail does NOT exist on CustomerInfo or the quote schema (quoteWriteSchema). Only customerPhone is captured. The mailto: link uses phone number as the to field (a no-op in most clients).

Customer Approval Page (QuoteApprove.tsx)

Loading

  • Public route: /quote/approve/:token — no auth required
  • loadQuote() queries PocketBase using shareToken as both filter and query param (PB API rule gating)
  • On load, stamps viewedAt on the quote record via stampViewed() — fires once per quote

Approval Flow

  1. Customer sees per-service Approve/Decline toggle buttons
  2. Must make a decision on every visible service before submitting
  3. Must enter their full name as signature
  4. handleSubmitAll() (line 159) writes per-service decisions to PocketBase:
    • customerDecision: 'approved' / 'declined'
    • approved: true / false
    • authorizedBy: customer's name
    • authorizedAt: ISO timestamp
    • authorizedMethod: 'signature'
  5. Top-level quote status computed:
    • All approved → 'approved'
    • All declined → 'declined'
    • Mixed → 'sent' (stays "pending" in UI filters)

Security Gaps

  • No identity verification: The share token (UUID) is the only gate. Anyone with the URL can approve/decline. No phone/email verification, no PIN, no expiry.
  • No customer email field: CustomerInfo type lacks email. Only repair orders and invoices have customerEmail. No way to send the share link to an email address even if email sending were implemented.
  • No expiry: Roadmap item 4.2 suggests PB onRecordBeforeUpdateRequest hook checking quoteExpiryDays from settings, but not implemented.

How the Shop Knows

Dashboard Real-Time Subscription (Dashboard.tsx:157-189)

unsub = await pb.collection('quotes').subscribe('*', (data) => {
  // Delta-applies quote changes to the in-memory list
  // Stats (approved count, pending review) update reactively
});
  • Live on Dashboard only: Stats update without refresh while on that page
  • No proactive notification anywhere: No toast popup, no browser Notification API, no sound, no email/SMS sent to the shop
  • Other pages: Quotes page (QuoteGenerator.tsx) must be manually refreshed

Toggle in Settings

  • Settings → Notifications tab has "Email Notifications" toggle (description: "Receive email notifications for quote updates and approvals")
  • Placard only: Saved to localStorage as spq-emailNotifications. No backend code reads this preference. No email is ever sent regardless of toggle state.
  • Roadmap 4.1 describes a PB onRecordAfterUpdateRequest hook that would auto-send email via PB SMTP when quote status changes from sent to approved/declined — not implemented.

Verified Capabilities & Gaps Matrix

Capability Status Where
Generate share link with UUID token Done QuoteSummary.tsx:104-146
Copy link to clipboard Done QuoteSummary.tsx:164
Open local email/SMS draft ⚠️ mailto:/sms: only, no server sending contactLinks.ts
Customer views & stamps viewedAt Done QuoteApprove.tsx:56-67
Per-service approve/decline Done QuoteApprove.tsx:155-157
Per-service authorizedBy/authorizedAt tracking Done QuoteApprove.tsx:180-192
Quote status update (approved/declined/sent) Done QuoteApprove.tsx:198-205
Real-time Dashboard updates Done Dashboard.tsx:162-183
Toast/notification when customer responds Missing
Browser Notification API alert Missing
Email sent to shop on approval Missing Planned at roadmap 4.1
Phone/last-4 verification Missing
Quote expiry Missing Planned at roadmap 4.2
customerEmail field on quote form Missing Only on RO/Invoice schemas
Server-side email sending Missing No SMTP util in codebase
Server-side SMS sending Missing Would need Twilio/etc
  • 4.1 Quote approval notifications: PB hook sends email when status flips from sent to approved/declined. Requires PB SMTP from 3.2 (already configured in Docker env per Ray).
  • 4.2 Quote expiry enforcement: PB hook blocks updates if quoteExpiryDays exceeded and request uses shareToken query param.

Approximate Approach for Adding Identity Verification (least-infrastructure option)

  1. Add verificationCode (short random string, e.g. last-4-of-phone or 4-digit PIN) to the quote PB record — set it when generating the share link
  2. On QuoteApprove.tsx load, if verification code exists, show a code-entry prompt before revealing services
  3. Customer enters the code → matches → show the approval UI
  4. The verification code can be communicated separately (verbally, or via SMS/email through existing channels)
  5. No server-side infrastructure beyond the PB record field