Skip to content

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_codes reference table
  • Retention interventions routed per reason code (transition_support, discount_15, frequency_change, pause_offer, compassionate_acknowledgement)
  • Funnel steps logged to subscription_actions with cancel_flow_id in meta
  • Structured reason recorded to cancellation_reasons with 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-tracking Edge 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_tokens table (renamed from old portal_sessions) holds magic-link tokens
  • portal_sessions table 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-logout Edge Function
  • Frontend stores session_token in 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_action revoked from PUBLIC
  • update_customer_address (public schema) revoked from anon/authenticated/PUBLIC

Subscription Actions (Phase 2a)

  • All 7 actions migrated from Make.com webhook to portal-subscription-action Edge 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-address Edge 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_captured and per-dog has_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.uk mailto 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

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_at column 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_codes reference table
  • Retention interventions routed per reason code (transition_support, discount_15, frequency_change, pause_offer, compassionate_acknowledgement)
  • Funnel steps logged to subscription_actions with cancel_flow_id in meta
  • Structured reason recorded to cancellation_reasons with 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-token header (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_captured should be false, dogs[].has_baseline should 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_credits was revoked from anon. Credits are now loaded in the dashboard response.
  • Check: Credits should appear in the portal-session response as credits object.

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 PUBLIC
  • public.update_customer_address — revoked from anon, authenticated, PUBLIC

Disabled Triggers

  • sync-address-change on raw_ops.address_changes — was firing Make.com webhook via sync-address-change Edge Function

Removed Frontend Code

  • submitAction() — Make.com webhook call
  • logSubscriptionAction() — 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)
  • sessionStorage baseline flags (replaced by server-side baseline_captured)
  • Direct customer_id parameters 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)

  • 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