Protocol Raw Customer Portal Documentation v3.0¶
| Field | Value |
|---|---|
| Document ID | SOP-WEB-PORTAL-03 |
| Version | 3.1 |
| Status | Active |
| Last Updated | 2026-04-09 |
| Owner | Anton Sealy |
| Portal URL | https://my.protocolraw.co.uk |
1. Overview¶
The Customer Portal is a self-serve subscription management application for Protocol Raw customers. It allows customers to view their subscription, manage deliveries, update payment and address details, capture health baselines for their dogs, and share referral links.
Technology stack:
- Frontend: React 19, Vite 7, JavaScript/JSX
- Hosting: Cloudflare Pages (auto-deploy on push to main)
- Backend: Supabase (PostgreSQL + Edge Functions)
- Subscription provider: Seal Subscriptions (Shopify app)
- Payments: Shopify + Seal hosted payment pages
- Emails: Customer.io (transactional + lifecycle)
Repository: protocolraw/protocol-raw-customer-portal
2. What's New in v3.1 (2026-04-09)¶
Cancellation Flow v2.0¶
- 4-step modal flow: category → sub-reason (conditional) → intervention → final confirmation
- 8 reason categories, 15 reason codes loaded from
cancellation_reason_codesreference table - Retention interventions routed per reason code (transition_support, discount_15, frequency_change, pause_offer, compassionate_acknowledgement)
- Funnel steps logged to
subscription_actionswith cancel_flow_id in meta - Structured reason recorded to
cancellation_reasonswith FK-validated category/code pairs - Outcome state machine: states advance only after downstream action confirms
- cancel_confirmed set only by Seal webhook via fn_confirm_cancellation_from_seal_v1
- Terminal states cannot be overwritten by client retries (monotonic upsert)
- 3 analytics views: v_cancellation_analysis, v_sku2_signal, v_cancel_intervention_effectiveness
- CancelFlow component directory (6 files) replaces inline CancelView
cancellation-trackingEdge Function rewritten with 4 routes (GET codes, POST record, POST log-step, POST update-outcome)
What was new in v3.0.1 (2026-04-03)¶
Cancellation Flow (v1, now superseded by v2.0 above)¶
- Five reason-specific counter-offer screens (too_expensive, dog_wont_eat, switching_brand, no_longer_need, other)
- Box size downgrade offer for too_expensive (12kg/16kg customers)
- Weekly pricing in save offers (matches calculator framing)
- Feeding help guide link for dog_wont_eat
- Competitive intelligence capture for switching_brand
- Free text capture for other
- Safe confirmation copy ("You won't be charged for future renewals")
- Conditional upcoming-order notice on confirmation screen
Feeding Help View¶
- New view at
?view=feeding-help - Four-section transition troubleshooting guide (cold food, pace, treats, reassurance)
- Not indexed, not in Journal, not in navigation
- Surfaces via cancellation flow counter-offer and Week 1 lifecycle email (secondary link)
Account Section Fixes¶
- Log out moved to header (Friar Gray, subtle)
- Duplicate "Contact support" removed from Account card (footer version kept)
- Cancel subscription / Log out rendering bug fixed
What was new in v3.0¶
Session Authentication Model¶
- Magic-link tokens are now one-time use (redeemed into session tokens)
portal_tokenstable (renamed from oldportal_sessions) holds magic-link tokensportal_sessionstable holds authenticated sessions (UUID session_token, 7-day absolute expiry)- Single active session per customer (new login revokes prior sessions)
- Server-side logout via
portal-logoutEdge Function - Frontend stores
session_tokenin localStorage, global 401 handler
Security Hardening¶
- IDOR vulnerabilities fixed on:
get-payment-update-url,health-baseline-submit,update_customer_address, all subscription actions - Customer identity derived server-side from session token; never sent from frontend
- Anon SELECT grants revoked on:
portal_tokens,subscriptions,customer_credits,address_changes - RLS enabled on 10 portal-accessible tables
- 8 dangerous anon-callable functions revoked (including
fn_trigger_3pl_export,fn_agent_get_customer_context) log_subscription_actionrevoked from PUBLICupdate_customer_address(public schema) revoked from anon/authenticated/PUBLIC
Subscription Actions (Phase 2a)¶
- All 7 actions migrated from Make.com webhook to
portal-subscription-actionEdge Function - Direct Seal API calls (pause, resume, cancel, skip, reschedule, change_frequency, change_box)
- PostgreSQL prepare/complete pattern with four-state action machine
- Idempotency keys (frontend-generated UUID, schema-enforced uniqueness)
- In-flight concurrency guard (one pending action per subscription)
- 48-hour delivery lock for schedule-affecting actions
- Canonical state projection from Seal API response
- Reconciliation predicates for ambiguous outcomes
Address Sync¶
- Migrated from Make.com to
portal-update-addressEdge Function - Direct Shopify Admin API + Seal API address sync
- Sync status tracked per-system (shopify_synced, seal_synced)
- Old database trigger disabled
Health Baseline¶
- Session-authenticated baseline submission (IDOR fixed)
- Dog ownership validation (
fn_validate_dog_ownership_v1) - Dashboard returns
baseline_capturedand per-doghas_baseline - Baseline detection uses server state instead of sessionStorage
- Scale selection requires explicit tap (no pre-selected defaults)
- Validation highlights incomplete scales
Referral Sharing¶
- Referral data folded into dashboard response (no separate endpoint)
- Copy/share button with Web Share API and clipboard fallback
- Stats line (referrals, confirmed, earned)
Other¶
- Support contact:
support@protocolraw.co.ukmailto link - Shared site navigation removed (portal has own bespoke header)
- Seal webhook handler updated with status idempotency guard
3. Architecture¶
Customer Browser (my.protocolraw.co.uk)
│
├─ portal-session ──────────────┐
├─ portal-logout │
├─ portal-subscription-action ├──► PostgreSQL (raw_ops schema)
├─ portal-update-address │ │
├─ get-payment-update-url │ ├──► Seal Subscriptions API
├─ health-baseline-submit ──────┘ ├──► Shopify Admin API
│ └──► Customer.io Track API
└─ request-portal-access (unauthenticated)
Edge Functions¶
| Function | Auth | Purpose |
|---|---|---|
portal-session |
Session token or magic-link token | Authenticate, load dashboard data |
portal-logout |
Session token | Revoke session |
portal-subscription-action |
Session token | Execute subscription actions via Seal API |
portal-update-address |
Session token | Update address, sync to Shopify + Seal |
get-payment-update-url |
Session token | Fetch Seal payment edit URL |
health-baseline-submit |
Session token | Submit health baseline scores |
request-portal-access |
None (public) | Generate magic-link token, send email |
cancellation-tracking |
Session token (x-session-token header) | Cancellation flow v2.0: GET codes, POST record reason, POST log-step, POST update-outcome |
Make.com is not used for any portal action. All external API calls are made directly from Edge Functions.
4. Authentication¶
Magic Link Flow¶
1. Customer requests access (email)
→ request-portal-access Edge Function
→ Generates 64-char hex token → portal_tokens table
→ Sends email via Customer.io (transactional message ID 3)
2. Customer clicks link (?token=<hex>)
→ Frontend extracts token from URL, cleans URL
→ Calls portal-session Edge Function with { token }
→ fn_redeem_portal_token_v1: marks token used_at, revokes prior sessions, creates new session
→ Returns session_token (UUID) + dashboard data
→ Frontend stores session_token in localStorage
3. Subsequent requests
→ Frontend sends session_token in request body
→ Edge Function calls fn_validate_portal_session_v1 → customer_id or NULL
→ Customer identity derived server-side
4. Logout
→ Frontend calls portal-logout with { session_token }
→ fn_revoke_portal_session_v1 sets revoked_at
→ Frontend clears localStorage
Session Rules¶
- One active (unrevoked, unexpired) session per customer
- New login revokes all prior sessions for that customer
- Absolute 7-day expiry (no sliding refresh)
last_seen_atcolumn exists but is not wired (future use)- 401 responses are generic (no reason codes for expired vs revoked)
portal_tokens Table¶
| Column | Type | Nullable | Notes |
|---|---|---|---|
| id | uuid | NO | PK, gen_random_uuid() |
| customer_id | uuid | NO | FK → customers |
| token | text | NO | 64-char hex, UNIQUE |
| created_at | timestamptz | NO | DEFAULT NOW() |
| expires_at | timestamptz | NO | DEFAULT NOW() + 7 days |
| last_accessed_at | timestamptz | YES | Legacy column |
| ip_address | text | YES | Legacy column |
| user_agent | text | YES | Legacy column |
| used_at | timestamptz | YES | Set on redemption |
portal_sessions Table¶
| Column | Type | Nullable | Notes |
|---|---|---|---|
| id | uuid | NO | PK, gen_random_uuid() |
| customer_id | uuid | NO | FK → customers |
| session_token | uuid | NO | UNIQUE |
| source_token_id | uuid | NO | FK → portal_tokens, UNIQUE |
| created_at | timestamptz | NO | DEFAULT NOW() |
| expires_at | timestamptz | NO | DEFAULT NOW() + 7 days |
| revoked_at | timestamptz | YES | Set on logout or new login |
| last_seen_at | timestamptz | YES | Not wired in v3.0 |
Key indexes:
- uq_portal_sessions_token — UNIQUE on session_token
- uq_portal_sessions_source_token — UNIQUE on source_token_id
- uq_portal_sessions_one_unrevoked_per_customer — UNIQUE on customer_id WHERE revoked_at IS NULL
- idx_portal_sessions_token_active — on session_token WHERE revoked_at IS NULL
5. Portal Features¶
Subscription Dashboard¶
Displays next delivery date, subscription status, box size, frequency, feeding plan, and order history. Data loaded via fn_get_portal_dashboard_v1 in the initial portal-session call.
Subscription Actions¶
Session-authenticated via portal-subscription-action Edge Function. All actions follow the prepare → Seal API → complete pattern.
Supported actions: pause, resume, cancel, skip, reschedule, change_frequency, change_box
Cancel Subscription (v2.0)
- 4-step modal flow: category → sub-reason (conditional) → intervention → final confirmation
- 8 reason categories, 15 reason codes loaded from
cancellation_reason_codesreference table - Retention interventions routed per reason code (transition_support, discount_15, frequency_change, pause_offer, compassionate_acknowledgement)
- Funnel steps logged to
subscription_actionswith cancel_flow_id in meta - Structured reason recorded to
cancellation_reasonswith FK-validated category/code pairs - Outcome state machine: states advance only after downstream action confirms
- cancel_request_submitted written only after portal-subscription-action succeeds
- cancel_confirmed set only by Seal webhook via fn_confirm_cancellation_from_seal_v1
- Terminal states cannot be overwritten by client retries (monotonic upsert)
Components:
- CancelFlowModal.jsx (parent, state management)
- CancelCategorySelect.jsx (Step 1)
- CancelSubreasonSelect.jsx (Step 2, conditional)
- CancelIntervention.jsx (Step 3, routes by intervention code)
- CancelFinalConfirm.jsx (Step 4)
- cancelFlowApi.js (API helper, 4 functions)
Action lifecycle:
1. Frontend creates idempotency key (crypto.randomUUID())
2. Edge Function validates session → customer_id
3. fn_prepare_subscription_action_v1: validates eligibility, state, 48-hour lock, creates pending action row
4. Edge Function calls Seal API directly
5. Edge Function GETs canonical state from Seal
6. fn_complete_subscription_action_v1: projects Seal state to local DB, marks action completed/failed/reconcile_required
48-hour delivery lock: Actions that affect the delivery schedule (skip, reschedule, pause, change_box, change_frequency) are blocked when next_billing_date is within 48 hours. Resume and cancel are exempt.
Idempotency: Frontend generates a UUID per user intent. The same key replays the existing result instead of creating a duplicate action. Enforced by partial unique index on (customer_id, action_type, idempotency_key).
In-flight guard: Partial unique index on (subscription_id) WHERE status = 'pending' prevents concurrent actions on the same subscription.
Shipment Tracking¶
Active shipment data loaded in the dashboard response via public.get_customer_active_shipment. Displays courier, tracking number, status, and event history.
Address Editing¶
Session-authenticated via portal-update-address Edge Function.
1. fn_prepare_address_change_v1: validates fields, updates customers table, creates address_changes audit row
2. Edge Function syncs to Shopify (Admin API PUT) and Seal (edit action) best-effort
3. fn_mark_address_synced_v1: tracks per-system sync status
4. Local write always succeeds; external sync failures are warnings
Payment Method Update¶
Session-authenticated via get-payment-update-url Edge Function.
1. Validates session → customer_id
2. fn_get_subscription_for_payment_update_v1: finds active/paused subscription
3. Calls Seal API to get fresh edit_url
4. Frontend redirects to Seal's hosted payment page
Credit Balance¶
Loaded in dashboard response via get_customer_credits. Displays formatted balance, credit history, and expiring-soon warnings.
Order History¶
Loaded in dashboard response. Shows recent paid orders with batch codes, verification status, proof URLs, tracking, and dispatch dates.
Feeding Plan¶
Loaded in dashboard response from feeding_plans table. Displays per-dog: name, weight, life stage, activity level, daily grams/kcal, box size.
Transition Guide¶
7-day raw feeding transition schedule rendered in TransitionView. Shown automatically on first visit.
Feeding Help View (v3.0.1)¶
Standalone troubleshooting guide at ?view=feeding-help. Four sections: cold food adjustment, transition pace, treat reduction, reassurance. Not indexed, not in navigation. Surfaces only via cancellation flow counter-offer (dog_wont_eat) and Week 1 lifecycle email (secondary link).
Health Baseline¶
Session-authenticated via health-baseline-submit Edge Function.
- Three scales: stool quality (1-7), coat condition (1-5), energy level (1-5)
- Shown in TransitionView before the transition schedule on first visit
- Multi-dog support (one card per dog without a baseline)
- Skip option; HomeView card prompts completion later
- fn_save_core_baseline_v1: saves baseline, logs to ops_events
- Dog ownership validated via fn_validate_dog_ownership_v1
- Duplicate baselines handled gracefully (returns baseline_already_exists)
- Triggers Customer.io Tier 2 email campaign for full baseline questionnaire
Referral Sharing¶
Loaded in dashboard response via get_customer_referral_info. Displays referral link with copy/share button, and stats (referrals, confirmed, earned). Web Share API on mobile with clipboard fallback.
Support Contact¶
Mailto link to support@protocolraw.co.uk displayed below the Account section.
6. Database Schema¶
subscription_actions¶
| Column | Type | Nullable | Notes |
|---|---|---|---|
| id | uuid | NO | PK |
| customer_id | uuid | NO | FK → customers |
| subscription_id | uuid | YES | FK → subscriptions |
| action_type | text | NO | CHECK: skip, reschedule, pause, resume, cancel, box_change, frequency_change, change_box, change_frequency, plan_update, cancel_flow_opened, cancel_category_selected, cancel_subreason_selected, cancel_offer_shown, cancel_offer_accepted, cancel_offer_declined, cancel_abandoned, cancel_confirm_requested |
| meta | jsonb | YES | DEFAULT '{}'. Cancel flow steps include cancel_flow_id |
| action_data | jsonb | YES | DEFAULT '{}' |
| source | text | NO | CHECK: portal, customer_portal, admin, customer_service, system, seal_webhook, ops_portal |
| created_at | timestamptz | NO | DEFAULT NOW() |
| seal_synced | boolean | YES | DEFAULT false |
| email_sent | boolean | YES | DEFAULT false |
| status | text | NO | DEFAULT 'completed'. Values: pending, completed, failed, reconcile_required |
| idempotency_key | uuid | YES | Frontend-generated |
| seal_response | jsonb | YES | Seal API response on completion |
| error_message | text | YES | Error details on failure |
| completed_at | timestamptz | YES | When action was completed/failed |
Key indexes:
- uq_subscription_actions_one_pending_per_subscription — UNIQUE on (subscription_id) WHERE status = 'pending'
- uq_subscription_actions_idempotency — UNIQUE on (customer_id, action_type, idempotency_key) WHERE idempotency_key IS NOT NULL
address_changes¶
| Column | Type | Nullable | Notes |
|---|---|---|---|
| id | uuid | NO | PK |
| created_at | timestamptz | YES | DEFAULT NOW() |
| customer_id | uuid | NO | FK → customers |
| changed_by | text | NO | Source identifier |
| agent_email | text | YES | For ops_portal changes |
| old_address | jsonb | NO | Previous address |
| new_address | jsonb | NO | New address |
| support_ticket_id | uuid | YES | FK → support_tickets |
| source | text | NO | CHECK: customer_portal, ops_portal, shopify, seal |
| shopify_synced | boolean | YES | DEFAULT false |
| shopify_synced_at | timestamptz | YES | |
| seal_synced | boolean | YES | DEFAULT false |
| seal_synced_at | timestamptz | YES | |
| reason | text | YES |
cancellation_reasons (v2.0)¶
| Column | Type | Nullable | Notes |
|---|---|---|---|
| id | uuid | NO | PK, gen_random_uuid() |
| customer_id | uuid | NO | FK → customers |
| subscription_id | uuid | YES | FK → subscriptions |
| reason | text | NO | Legacy column (v1 reason) |
| reason_category | text | YES | FK → cancellation_reason_categories |
| reason_code | text | YES | FK (composite with reason_category) → cancellation_reason_codes |
| reason_free_text | text | YES | Required when reason_code = 'other', btrim-validated |
| retention_intervention_code | text | YES | FK → retention_intervention_codes |
| retention_intervention_accepted | boolean | YES | Whether customer accepted the intervention |
| cancel_outcome | text | YES | CHECK: abandoned, support_requested, intervention_selected, intervention_applied, intervention_failed, cancel_request_submitted, cancel_request_failed, cancel_confirmed |
| is_voluntary | boolean | YES | No default. TRUE for portal cancellations |
| cancel_flow_id | uuid | YES | Unique partial index for idempotency |
| captured_at | timestamptz | YES | DEFAULT NOW() |
| confirmed_cancelled_at | timestamptz | YES | Set only from Seal webhook payload timestamp |
| created_at | timestamptz | NO | DEFAULT NOW() |
RLS enabled. Structured reason recorded after downstream action result known. Terminal states (intervention_applied, intervention_failed, cancel_request_failed, cancel_confirmed) cannot be overwritten by retries (monotonic upsert on cancel_flow_id).
customer_health_baselines¶
| Column | Type | Notes |
|---|---|---|
| id | uuid | PK |
| customer_id | uuid | FK → customers |
| dog_id | uuid | FK → feeding_plans |
| capture_completeness | text | 'core' or 'full' |
| stool_consistency | integer | 1-7 scale |
| coat_condition | integer | 1-5 scale |
| energy_level | integer | 1-5 scale |
| current_weight_kg | numeric | From feeding_plans |
| prior_diet_type | text | From calculator_discounts |
| capture_method | text | 'portal', 'email_link', 'onboarding_flow' |
| capture_season | text | spring/summer/autumn/winter |
| capture_month | integer | 1-12 |
| questionnaire_version | integer | |
| captured_at | timestamptz | |
| full_baseline_captured_at | timestamptz | Set when Tier 2 completed |
| (+ 20 Tier 2 columns) | Populated by full baseline questionnaire |
box_variant_map¶
| box_size | shopify_product_id | shopify_variant_id | sku | price_gbp |
|---|---|---|---|---|
| 8kg | 15217565499767 | 56069764350327 | PR-8KG-START | 89.00 |
| 12kg | 15217571889527 | 56069789974903 | NULL | 109.00 |
| 16kg | 15228962472311 | 56124470559095 | NULL | 129.00 |
7. PostgreSQL Functions¶
| Function | Parameters | Returns | Purpose |
|---|---|---|---|
fn_redeem_portal_token_v1 |
p_token TEXT | TABLE(customer_id, session_token, is_first_visit) | Redeem magic-link token, create session |
fn_validate_portal_session_v1 |
p_session_token UUID | UUID (customer_id or NULL) | Validate active session |
fn_revoke_portal_session_v1 |
p_session_token UUID | BOOLEAN | Revoke session (logout) |
fn_get_portal_dashboard_v1 |
p_customer_id UUID | JSONB | Load all dashboard data (customer, subscription, dogs, orders, stats, credits, shipment, referral, baseline_captured) |
fn_prepare_subscription_action_v1 |
p_customer_id, p_action_type, p_action_data, p_idempotency_key | TABLE(action_id, seal_subscription_id, current_status, current_box_size, seal_api_params) | Validate and prepare subscription action |
fn_complete_subscription_action_v1 |
p_action_id, p_outcome, p_seal_response, p_error_message | BOOLEAN | Project canonical state from Seal, update local DB |
fn_get_subscription_for_payment_update_v1 |
p_customer_id UUID | TABLE(seal_subscription_id, subscription_status) | Find subscription for payment update |
fn_validate_dog_ownership_v1 |
p_customer_id, p_dog_id | BOOLEAN | Verify dog belongs to customer |
fn_prepare_address_change_v1 |
p_customer_id, p_address1, p_address2, p_city, p_region, p_postcode, p_country_code | TABLE(change_id, shopify_customer_id, shopify_address_id, seal_subscription_id, old_address, new_address) | Validate and write address change |
fn_mark_address_synced_v1 |
p_change_id, p_system, p_success, p_error | VOID | Track Shopify/Seal sync status |
fn_save_core_baseline_v1 |
p_customer_id, p_dog_id, p_stool_consistency, p_coat_condition, p_energy_level | JSONB | Save health baseline (Tier 1) |
get_customer_credits |
p_customer_id UUID | JSONB | Credit balance and history |
get_customer_referral_info |
p_customer_id UUID | JSONB | Referral link, stats, rewards |
All functions are SECURITY DEFINER except get_customer_referral_info.
8. Edge Functions¶
portal-session¶
- Endpoint: POST /functions/v1/portal-session
- Auth: Magic-link token
{ token }or session token{ session_token } - Response:
{ success, session_token, is_first_visit, customer, subscription, dogs, orders, stats, credits, shipment, baseline_captured, referral } - Errors: 401 (invalid/expired), 500
portal-logout¶
- Endpoint: POST /functions/v1/portal-logout
- Auth: Session token
{ session_token } - Response:
{ success: true }(always, even for invalid tokens)
portal-subscription-action¶
- Endpoint: POST /functions/v1/portal-subscription-action
- Auth: Session token
- Request:
{ session_token, action, idempotency_key, data } - Response:
{ success, action, message }or{ success: false, error } - Errors: 400 (validation), 401 (session), 404 (no subscription), 409 (conflict/replay), 423 (48h lock), 502 (Seal failure)
portal-update-address¶
- Endpoint: POST /functions/v1/portal-update-address
- Auth: Session token
- Request:
{ session_token, address1, address2, city, region, postcode } - Response:
{ success, message, warning? } - Errors: 400 (validation), 401 (session), 500
get-payment-update-url¶
- Endpoint: POST /functions/v1/get-payment-update-url
- Auth: Session token
{ session_token } - Response:
{ edit_url }or{ error } - Errors: 401, 404, 409 (multiple subs), 502 (Seal error)
health-baseline-submit¶
- Endpoint: POST /functions/v1/health-baseline-submit
- Auth: Session token
{ session_token } - Request:
{ session_token, dog_id, stool_consistency, coat_condition, energy_level } - Response:
{ success, baseline_id, capture_completeness } - Errors: 400 (validation), 401 (session), 403 (wrong dog)
request-portal-access¶
- Endpoint: POST /functions/v1/request-portal-access
- Auth: None (public)
- Request:
{ email } - Response:
{ success: true }(always, to prevent email enumeration)
cancellation-tracking (v2.0)¶
- Endpoint: /functions/v1/cancellation-tracking
- Auth:
x-session-tokenheader (validated via fn_validate_portal_session_v1) - Routes:
- GET / — Return categories + codes for portal UI. Response:
{ categories, codes } - POST / — Record structured cancellation reason. Request:
{ cancel_flow_id, reason_category, reason_code, reason_free_text?, retention_intervention_code?, retention_intervention_accepted?, cancel_outcome }. Response:{ ok, cancellation_reason_id } - POST /log-step — Log cancel flow funnel step. Request:
{ action_type, cancel_flow_id, meta? }. Response:{ ok, action_id } - POST /update-outcome — Advance outcome state machine. Request:
{ cancel_flow_id, new_outcome }. Response:{ ok } - Notes: All business logic in PostgreSQL functions (fn_get_cancellation_categories_v1, fn_get_cancellation_reason_codes_v1, fn_log_cancel_flow_step_v1, fn_record_cancellation_reason_v1, fn_update_cancellation_outcome_v1). Uses service role. Customer identity derived server-side.
9. Security Model¶
Session-Based Authentication¶
All portal API calls (except request-portal-access) require a valid session token. Customer identity is derived server-side via fn_validate_portal_session_v1. The frontend never sends customer_id in action requests.
Row Level Security¶
| Table | RLS Enabled |
|---|---|
| portal_tokens | Yes |
| portal_sessions | Yes |
| customers | Yes |
| subscriptions | Yes |
| address_changes | Yes |
| customer_credits | Yes |
| customer_health_baselines | Yes |
| orders | Yes |
| shipments | Yes |
| box_variant_map | Yes |
| feeding_plans | No (accessed via SECURITY DEFINER functions) |
| subscription_actions | No (accessed via SECURITY DEFINER functions) |
No RLS policies exist — deny-all for anon/authenticated. All data access goes through SECURITY DEFINER functions which bypass RLS.
Anon Grant Lockdown¶
Portal-sensitive tables have zero anon SELECT grants: portal_tokens, subscriptions, customer_credits, address_changes.
Revoked anon-callable functions: get_customer_credits, fn_agent_get_customer_context, fn_agent_get_order_status, fn_get_orders_needing_instructions, fn_trigger_3pl_export, get_order_box_size, get_primary_batch_for_order, count_paid_orders_by_email, log_subscription_action (PUBLIC), update_customer_address (anon/authenticated/PUBLIC).
Remaining anon-callable functions (approved): validate_postcode_for_delivery, calculate_pcm_requirements, get_referral_by_slug, fn_rate_message, insert_logger_data.
Dog Ownership Validation¶
fn_validate_dog_ownership_v1 verifies a dog (feeding_plans row) belongs to the authenticated customer before allowing health baseline submission.
10. Frontend File Structure¶
customer-portal/
src/
main.jsx # Entry point (renders Portal directly, no shared nav)
Portal.jsx # Main component — all views and logic
LandingPage.jsx # Public batch lookup page
api.js # Session-authenticated API layer
utils.js # Feeding calculations, BOX_OPTIONS, FREQUENCY_OPTIONS
components.jsx # Reusable UI components
components/
CancelFlow/
CancelFlowModal.jsx # Cancel flow parent (state management, step routing)
CancelCategorySelect.jsx # Step 1: category selection
CancelSubreasonSelect.jsx # Step 2: sub-reason selection (conditional)
CancelIntervention.jsx # Step 3: intervention screen
CancelFinalConfirm.jsx # Step 4: final confirmation
cancelFlowApi.js # API helper (4 functions)
index.html # HTML entry (fonts only, no external scripts)
vite.config.js
package.json # React 19, Vite 7
api.js Exports¶
| Function | Purpose |
|---|---|
getTokenFromUrl() |
Extract ?token= from URL |
getSessionToken() |
Read from localStorage |
setSessionToken(token) |
Write to localStorage |
clearSession() |
Remove from localStorage |
authenticate(token) |
Redeem magic link → session + dashboard |
refreshDashboard() |
Reload dashboard via session token |
updateDogPlan(customerId, dogId, planData) |
Update feeding plan |
updateAddress(addressData) |
Session-auth address update |
createActionAttempt(action, data) |
Create idempotency key for action |
executeSubscriptionAction(attempt) |
Execute subscription action |
getActiveShipment(customerId) |
Fetch shipment (legacy RPC) |
getCustomerCredits(customerId) |
Fetch credits (legacy RPC) |
getPaymentUpdateUrl() |
Session-auth payment URL |
submitHealthBaseline(dogId, stool, coat, energy) |
Session-auth baseline |
logout() |
Revoke session + clear local state |
setSessionExpiredHandler(handler) |
Register global 401 handler |
Constants (utils.js)¶
BOX_OPTIONS = [
{ size: '8kg', grams: 8000, label: '8kg Box' },
{ size: '12kg', grams: 12000, label: '12kg Box' },
{ size: '16kg', grams: 16000, label: '16kg Box' },
]
FREQUENCY_OPTIONS = [
{ weeks: 2 }, { weeks: 3 }, { weeks: 4 }, { weeks: 5 }, { weeks: 6 }
]
11. Deployment¶
Frontend (Cloudflare Pages)¶
- Repository:
protocolraw/protocol-raw-customer-portal - Branch:
main - Build command:
npm run build - Output directory:
dist - Auto-deploy on push
Edge Functions (Supabase)¶
- Repository:
protocolraw/protocol-raw(edge-functions directory) - Deploy:
supabase functions deploy <function-name> --no-verify-jwt - All portal Edge Functions have
verify_jwt = false(session token is the credential) - CI/CD: GitHub Actions deploys all functions on push to
master
Environment Variables¶
Supabase (auto-injected): SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
Supabase secrets (manually set): - SEAL_API_TOKEN — Seal Subscriptions API token - SEAL_API_SECRET — Seal webhook HMAC secret - SHOPIFY_STORE — Shopify store domain (jggxmv-4y.myshopify.com) - SHOPIFY_ACCESS_TOKEN — Shopify Admin API token - CUSTOMERIO_SITE_ID — Customer.io site ID - CUSTOMERIO_API_KEY — Customer.io API key - CUSTOMERIO_APP_API_KEY — Customer.io transactional API key
12. Troubleshooting¶
Session expired or "logged out on another device"¶
- Cause: New login revoked prior session, or session > 7 days old
- Check:
SELECT revoked_at, expires_at FROM raw_ops.portal_sessions WHERE session_token = '<token>' - Fix: Customer requests new magic link
Subscription action fails¶
- Check:
SELECT status, error_message, seal_response FROM raw_ops.subscription_actions WHERE id = '<action_id>' - Check:
SELECT message, meta FROM raw_ops.ops_events WHERE entity_type = 'SUBSCRIPTION' ORDER BY created_at DESC LIMIT 10 - Status = pending: Action was sent to Seal but completion wasn't recorded. Check Edge Function logs.
- Status = failed: Seal rejected the action. Check error_message.
- Status = reconcile_required: Ambiguous outcome. Manual check needed against Seal dashboard.
Address sync fails¶
- Check:
SELECT shopify_synced, seal_synced FROM raw_ops.address_changes WHERE id = '<change_id>' - Check:
SELECT message FROM raw_ops.ops_events WHERE entity_type = 'ADDRESS' AND entity_id = '<change_id>' - Local address is always correct (updated before sync attempt). Re-trigger sync manually if needed.
Health baseline not showing¶
- Check dashboard:
baseline_capturedshould be false,dogs[].has_baselineshould be false - Check DB:
SELECT * FROM raw_ops.customer_health_baselines WHERE customer_id = '<id>' - Baseline already exists: Function returns
baseline_already_exists— this is expected for duplicate submissions
Credits not loading¶
- Cause:
get_customer_creditswas revoked from anon. Credits are now loaded in the dashboard response. - Check: Credits should appear in the
portal-sessionresponse ascreditsobject.
13. Archived / Removed¶
Make.com Scenarios (archived, not deleted)¶
- PORTAL-01: Address sync (replaced by
portal-update-address) - PORTAL-02 through PORTAL-07: Subscription actions (replaced by
portal-subscription-action)
Revoked Functions¶
raw_ops.log_subscription_action— revoked from PUBLICpublic.update_customer_address— revoked from anon, authenticated, PUBLIC
Disabled Triggers¶
sync-address-changeonraw_ops.address_changes— was firing Make.com webhook viasync-address-changeEdge Function
Removed Frontend Code¶
submitAction()— Make.com webhook calllogSubscriptionAction()— direct RPC to log_subscription_action- Make.com webhook URL (
hook.eu2.make.com/vaixfqo7bhw4njflhiee67b27hkw3xlp) - Shared site navigation (
<protocol-header>,<protocol-footer>, protocol-nav.js) sessionStoragebaseline flags (replaced by server-sidebaseline_captured)- Direct
customer_idparameters in API calls
14. Version History¶
| Version | Date | Changes |
|---|---|---|
| 3.1 | 2026-04-09 | Cancellation flow v2.0: 8-category, 15-code structured taxonomy with FK-validated reason pairs, retention intervention routing, honest outcome state machine (cancel_outcome), funnel instrumentation via subscription_actions, Seal webhook confirmation (fn_confirm_cancellation_from_seal_v1), 3 reference tables, 6 new PostgreSQL functions, 3 analytics views. CancelFlow component directory (6 files). cancellation-tracking Edge Function rewritten with 4 routes. |
| 3.0.1 | 2026-04-03 | Cancellation flow overhaul (5 reason-specific counter-offers, instrumentation, cancellation-tracking Edge Function). Feeding help view (?view=feeding-help). Account section fixes (log out to header, duplicate support removed). |
| 3.0 | 2026-04-02 | Session auth model, IDOR fixes, subscription actions via Edge Function, address sync via Edge Function, health baseline session auth, referral sharing, support contact, security hardening, Make.com elimination |
| 2.7 | 2026-03-01 | Health data infrastructure tables, baseline submission |
| 2.6 | 2026-02-15 | Credit balance display, referral system integration |
| 2.5 | 2026-01-17 | Payment update URL via Seal API |
| 2.0 | 2025-12-10 | Portal v2 launch (subscription management, feeding plans, order history) |
| 1.0 | 2025-11-29 | Initial portal (batch lookup, basic subscription view) |
15. Related Documentation¶
- SOP-SUB-00: Subscription State Management
- SOP-REF-01: Referral System
- SOP-HDI-01: Health Data Infrastructure
- SOP-MON-01: Monitoring and Alerting
- Visual Identity Guide v2.4
- Brand Voice and Copy Guidelines v1.4
- Protocol Raw Operations Portal Documentation v3.8