Raxx · internal docs

internal · gated ↑ index

support.raxx.app — Customer Support Portal Design

Status: Accepted Date: 2026-05-03 UTC Owner: software-architect Parent epic: #651 ADRs: 0046 · 0045 Downstream sub-cards: TBD — filed with this design


1. Context

support.raxx.app is the customer-facing support portal for Raxx. It is the front door customers walk through to open, track, and reply to support tickets. It launches in the first 30 days post-v1.0 — after #707 establishes the email-intake pipeline that serves as the v1.0-day-1 support experience.

The portal sits on top of FreeScout (running at tickets.raxx.app, CF Access gated — operators only). All FreeScout interaction is mediated server-side by Raptor. No FreeScout name, URL, or credential ever reaches the customer browser.

The design mirrors the status.raxx.app pattern (ADR-0028): a CF Pages static React SPA calling Raptor API endpoints that act as a privacy-enforcing proxy.


2. Invariants

All platform invariants apply. Support-portal-specific constraints:

  1. No stored credentials. The FreeScout API key is a server-side secret only. It never touches CF Pages build output, the browser bundle, or any env var accessible to client code. It sits in Infisical (see ADR-0046) and is loaded into Raptor at runtime via env.
  2. Passkeys / WebAuthn only. Customers authenticate with the existing Raxx passkey flow (#110 / epic #146). No password, no SMS OTP, no email OTP fallback. If a customer cannot authenticate, per the A+B recovery policy (#670, confirmed 2026-04-30), the account is unrecoverable. The portal must not create an alternate auth path.
  3. Privacy boundary is absolute. Raptor MUST verify on every /api/v1/support/tickets/{id} response — not just the list endpoint — that the ticket's customer email matches the JWT customer_email claim. Customer A must never see Customer B's ticket. This check happens server-side, not in the React app.
  4. Internal notes are never exposed. FreeScout thread entries with type: note are stripped server-side before any response leaves Raptor. Operator fields (assignee, tags, component_tag, incident_severity, _internal_* custom fields) are never included in customer responses.
  5. No forward-looking copy. Error states, status messages, and ticket summaries describe what has happened, not what will happen. No ETAs, no "we expect to resolve by." This applies to in-portal copy and email notification templates.
  6. Audit trail for every support interaction. Every ticket create, reply, status change, and customer-identity lookup is written to the audit log with actor=customer:{customer_id}, timestamp, and action. Audit rows are retained per the GDPR retention policy (90 days for interaction audit; see §8).
  7. No vendor names visible to customers. The words "FreeScout," "Tagras," or any underlying vendor name never appear in customer-facing copy, URL structures, error messages, or email templates.

3. Topology Decision

Selected: Option A — New CF Pages project raxx-support with Raptor API proxy.

See ADR-0045 for the full decision record and alternatives.

customer browser
   └── support.raxx.app  (CF Pages, static React SPA — project: raxx-support)
          │  HTTPS, CF-proxied, no CF Access gate
          │
          └── api.raxx.app/api/v1/support/*  (Raptor — raxx-api-prod)
                 │  service-to-service with FreeScout API token
                 │  (Infisical secret, server-side only)
                 │
                 └── tickets.raxx.app  (FreeScout REST API — CF Access gated)

Key topology properties: - CF Pages project raxx-support mirrors the raxx-status pattern (ADR-0028). - Raptor endpoints are added to the existing raxx-api-prod Heroku dyno. No new Heroku app. This satisfies the BLR constraint from #908. - The FreeScout API token is a Raptor-side secret only. CF Pages has no access to it. - support.raxx.app has no Cloudflare Access gate — it is a public-access surface (Class 1 per docs/security/web-surface-posture.md). All ticket data requires app-level authentication.


4. Data Model

4a. Customer ↔ FreeScout mapping table

FreeScout identifies customers by email within a mailbox. Its customer_id is per-mailbox and may be reassigned if a customer is deleted and re-created. Raptor needs a stable raxx_user_id ↔ freescout_customer_id mapping with the customer email as the join key.

-- Migration: backend_v2/db/migrations/NNNN_support_customer_map.sql

CREATE TABLE support_customer_map (
    raxx_user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    freescout_customer_id INTEGER NOT NULL,               -- FreeScout numeric customer ID
    customer_email       TEXT NOT NULL,                   -- denormalized; the join key; indexed
    freescout_mailbox_id INTEGER NOT NULL,                -- which FreeScout mailbox
    created_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_verified_at     TIMESTAMPTZ,                     -- last time Raptor confirmed the mapping is live
    PRIMARY KEY (raxx_user_id, freescout_mailbox_id)
);

CREATE UNIQUE INDEX support_customer_map_email_mailbox
    ON support_customer_map (customer_email, freescout_mailbox_id);

Resolution logic: When a customer's first support request arrives, Raptor calls GET /api/customers?email={email} on FreeScout. If a customer record exists, store the freescout_customer_id. If not, FreeScout auto-creates a customer on first POST /api/conversations — Raptor reads the created customer ID from the response and stores it. The email in the mapping always mirrors the JWT customer_email claim.

On user deletion (GDPR DSR): the ON DELETE CASCADE removes the mapping row. FreeScout conversations remain for the operator's retention window; the mapping row is gone so Raptor can no longer serve them via this customer's account. See §8 for retention details.

4b. Support audit log

CREATE TABLE support_audit_log (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      TEXT NOT NULL,                       -- raxx_user_id; NOT email
    action           TEXT NOT NULL,                       -- 'ticket.list' | 'ticket.read' | 'ticket.create' | 'ticket.reply' | 'ticket.resolve'
    resource_id      TEXT,                                -- freescout conversation_id (opaque string to customer)
    ip_prefix        TEXT,                                -- /24 or /48 of requester IP
    session_id_hash  TEXT,                                -- SHA-256 of the session ID; not the raw session
    success          BOOLEAN NOT NULL DEFAULT TRUE,
    error_code       TEXT,                                -- null on success; 'privacy_violation' | 'not_found' | etc.
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX support_audit_log_customer ON support_audit_log (customer_id, created_at DESC);
CREATE INDEX support_audit_log_created  ON support_audit_log (created_at DESC);  -- for retention job

Audit rows with action = 'ticket.list' are retained for 30 days (low-sensitivity). Rows with action touching create, reply, or resolve are retained for 90 days. The nightly retention job deletes rows per these windows.

4c. Offline ticket queue (FreeScout unavailable)

CREATE TABLE support_pending_submissions (
    id               TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    submission_type  TEXT NOT NULL CHECK (submission_type IN ('new_ticket', 'reply')),
    conversation_id  TEXT,                                -- null for new_ticket
    subject          TEXT,
    body             TEXT NOT NULL,
    submitted_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    attempts         INTEGER NOT NULL DEFAULT 0,
    last_attempt_at  TIMESTAMPTZ,
    delivered_at     TIMESTAMPTZ,                         -- null = not yet delivered
    error            TEXT                                 -- last error message on delivery failure
);

When Raptor cannot reach FreeScout, new ticket and reply submissions are held here. A background task retries delivery on a 5-minute interval, up to 24 hours. After 24 hours, the submission is marked error = 'expired' and the customer is notified via email that their message was not delivered and they should re-submit via email to support@raxx.app.


5. API Contract

All endpoints are under api.raxx.app/api/v1/support/*. Every endpoint requires a valid Raxx session (JWT Bearer token or session cookie). Rate limit: 60 req/min per customer (shared with the default Free tier bucket per session-engine.md §6).

The support endpoints are not behind the paper-first gate — support is not a trading code path.

5a. Endpoint table

Method Path FreeScout proxy Auth Rate limit
GET /api/v1/support/tickets GET /api/conversations?customer[email]={email}&mailbox=support Required 60/min
GET /api/v1/support/tickets/{id} GET /api/conversations/{id}/threads Required 60/min
POST /api/v1/support/tickets POST /api/conversations Required 10/min (tighter)
POST /api/v1/support/tickets/{id}/replies POST /api/conversations/{id}/threads Required 20/min (tighter)
PUT /api/v1/support/tickets/{id}/resolve PUT /api/conversations/{id} {status: "resolved"} Required 10/min
GET /api/v1/support/tickets/{id}/attachments/{aid} GET /api/conversations/{id}/threads/{tid}/attachments Required 30/min

5b. GET /api/v1/support/tickets

Request: No body. Auth: Bearer JWT.

FreeScout call: GET /api/conversations?customer[email]={jwt.customer_email}&mailbox={SUPPORT_MAILBOX_ID}&sortField=updatedAt&sortOrder=desc

Privacy enforcement: Raptor uses jwt.customer_email — not a query param from the client — as the filter. Clients cannot enumerate other customers' tickets by passing a different email.

Response (200):

{
  "tickets": [
    {
      "id": "4821",                    // opaque string (FreeScout conversation ID cast to string)
      "subject": "Issue with my backtest",
      "status": "open",                // "open" | "waiting_for_you" | "resolved"
      "created_at": "2026-05-01T14:22:00Z",
      "updated_at": "2026-05-03T09:15:00Z",
      "unread": true
    }
  ],
  "total": 1
}

Status mapping: FreeScout active"open", pending"waiting_for_you", closed"resolved". No other FreeScout status values are surfaced.

Stripped fields: assignee, tags, customFields, createdBy (operator ID), firstReplyAt (internal SLA field), all _internal_ custom fields.

5c. GET /api/v1/support/tickets/{id}

Privacy check: Before fetching the thread, Raptor calls GET /api/conversations/{id} and verifies conversation.customer.email == jwt.customer_email. If they do not match: 403 with body {"error": "not_found"} (deliberately vague — do not confirm that the ticket exists). This check is performed on every request, not cached.

FreeScout call: GET /api/conversations/{id}/threads

Response (200):

{
  "id": "4821",
  "subject": "Issue with my backtest",
  "status": "open",
  "created_at": "2026-05-01T14:22:00Z",
  "updated_at": "2026-05-03T09:15:00Z",
  "threads": [
    {
      "id": "t_9901",                  // opaque thread ID
      "from": "customer",              // "customer" | "support"
      "body": "My backtest shows...",
      "sent_at": "2026-05-01T14:22:00Z",
      "attachments": [
        { "id": "att_001", "filename": "screenshot.png", "size_bytes": 48210 }
      ]
    },
    {
      "id": "t_9902",
      "from": "support",
      "body": "Thanks for writing in. We looked at...",
      "sent_at": "2026-05-02T10:30:00Z",
      "attachments": []
    }
  ]
}

Stripped from threads: type: note entries (internal notes — dropped entirely), createdBy operator ID, source (email/API/web), FreeScout user attribution beyond "support".

404 handling: If FreeScout returns 404 (ticket deleted operator-side), Raptor returns {"error": "not_found", "message": "This ticket is no longer available."} — no FreeScout-specific error. The portal shows a Bandz-in-scenario empty state.

5d. POST /api/v1/support/tickets

Request body:

{
  "subject": "My backtest won't run",
  "body": "Steps to reproduce: ...",
  "category": "bug_report"             // optional: "account" | "billing" | "bug_report" | "feature_request"
}

FreeScout call: POST /api/conversations

{
  "mailboxId": "{SUPPORT_MAILBOX_ID}",
  "subject": "{subject}",
  "customer": { "email": "{jwt.customer_email}" },
  "threads": [{ "type": "customer", "body": "{body}", "from": { "email": "{jwt.customer_email}" } }],
  "status": "active",
  "tags": ["{category}"]               // if category provided; maps to FreeScout tags
}

Fallback: If FreeScout is unreachable, insert into support_pending_submissions, return 202 Accepted with {"status": "queued", "message": "Your message was received and will be delivered shortly."}.

Response (201):

{
  "id": "4825",
  "subject": "My backtest won't run",
  "status": "open",
  "created_at": "2026-05-03T11:00:00Z"
}

5e. POST /api/v1/support/tickets/{id}/replies

Privacy check: Same as §5c — verify ticket ownership before writing.

Request body:

{
  "body": "I also noticed that..."
}

FreeScout call: POST /api/conversations/{id}/threads

{
  "type": "message",
  "body": "{body}",
  "from": { "email": "{jwt.customer_email}" },
  "fromCustomer": true,
  "status": "active"
}

Response (201):

{
  "thread_id": "t_9903",
  "sent_at": "2026-05-03T11:05:00Z"
}

5f. PUT /api/v1/support/tickets/{id}/resolve

Privacy check: Verify ticket ownership.

FreeScout call: PUT /api/conversations/{id} with {"status": "closed"}.

Response (200):

{ "id": "4821", "status": "resolved" }

5g. GET /api/v1/support/tickets/{id}/attachments/{aid}

Privacy check: Verify ticket ownership before serving any attachment.

Raptor fetches the attachment binary from FreeScout and streams it to the client. Content-Disposition, Content-Type, and Content-Length are forwarded. The FreeScout attachment URL is never exposed to the client; all fetches go through Raptor.

Response: Binary stream with appropriate Content-Type.


6. Authentication Flow

6a. Cross-domain session strategy

support.raxx.app is a different origin from raxx.app and api.raxx.app. The session cookie issued by Raptor uses Domain=raxx.app with SameSite=None; Secure so it is sent with cross-origin requests from support.raxx.app to api.raxx.app. The cookie is still HttpOnly.

Alternatively, the SPA can use the Authorization: Bearer <jwt> header path (per session-engine.md §4) — this is the recommended path for the support portal since it avoids cross-origin cookie complexity. The JWT is stored in sessionStorage (not localStorage) — lost on tab close, short-lived (15-min TTL with refresh).

The portal uses JWT Bearer token in sessionStorage, not the session cookie. This is the cleanest cross-domain approach.

6b. Passkey authentication sequence

sequenceDiagram
    actor Customer
    participant SPA as support.raxx.app<br/>(CF Pages SPA)
    participant Raptor as api.raxx.app<br/>(Raptor)
    participant WebAuthn as Platform Authenticator<br/>(Touch ID / YubiKey)

    Customer->>SPA: Visits support.raxx.app
    SPA->>SPA: No JWT in sessionStorage → show sign-in screen
    Customer->>SPA: Clicks "Sign in with Raxx"
    SPA->>Raptor: GET /api/sessions/webauthn/challenge
    Raptor-->>SPA: {challenge, rpId: "raxx.app", timeout: 60000}
    SPA->>WebAuthn: navigator.credentials.get({publicKey: ...})
    WebAuthn-->>SPA: WebAuthn assertion
    SPA->>Raptor: POST /api/sessions  {assertion}
    Raptor->>Raptor: Verify assertion against stored public key
    Raptor->>Raptor: Mint JWT (15-min TTL) + session record
    Raptor-->>SPA: {jwt, expires_at, user_id}  [no cookie set]
    SPA->>SPA: Store JWT in sessionStorage
    SPA->>SPA: Render ticket list

Note on rpId: The WebAuthn rpId must be raxx.app (the registrable domain). The support portal at support.raxx.app is a sub-domain and therefore eligible to authenticate with credentials registered against raxx.app. No re-enrollment is required. This must be validated in the Raptor WebAuthn verification logic — rpId is confirmed against the stored credential's origin on registration.

6c. Session lifetime

Parameter Value
JWT TTL 15 minutes
Session sliding window Refresh on any authenticated request if < 5 min remaining
Absolute max session 12 hours (matches session-engine.md)
Step-up requirement Not required for support actions (no money, no credential changes)

6d. Logout

DELETE /api/sessions/current revokes the server-side session. The SPA clears sessionStorage. On session expiry (JWT TTL + no refresh), the portal shows the sign-in screen without a full page reload — graceful session recovery UX.


7. Email-Side Flow (Out-of-Portal Replies)

When a customer replies via email rather than the portal, FreeScout appends a new thread entry to the conversation. The portal must show this reply.

Decision: Portal reads from FreeScout on demand — no local cache of thread content.

Rationale: At 1–50 customers, thread volume is trivial. Caching thread content in Raptor adds complexity and a sync problem (when does the cache invalidate?). The portal re-fetches the full thread on every GET /api/v1/support/tickets/{id} open. The webhook path (FreeScout → Raptor → push to portal) is deferred to a post-launch iteration.

Email-reply flow:

sequenceDiagram
    actor Customer
    participant EmailClient as Customer Email Client
    participant FreeScout as tickets.raxx.app<br/>(FreeScout)
    participant Raptor as api.raxx.app
    participant SPA as support.raxx.app

    Customer->>EmailClient: Replies to "Raxx Support" email
    EmailClient->>FreeScout: Email delivered via MX (Postmark Inbound)
    FreeScout->>FreeScout: Appends thread entry to conversation
    Note over FreeScout: No push to portal; portal reads on demand
    Customer->>SPA: Opens portal, views ticket
    SPA->>Raptor: GET /api/v1/support/tickets/{id}
    Raptor->>FreeScout: GET /api/conversations/{id}/threads
    FreeScout-->>Raptor: Thread including new email-sent message
    Raptor-->>SPA: Thread with "from: customer" entry for the email reply

Notification loop: When an operator replies, FreeScout sends an outbound email to customer@example.com from support@raxx.app. The email includes a deep-link to https://support.raxx.app/tickets/{id}. The customer clicks through to the portal, authenticates, and sees the thread including the reply.


8. Notification Strategy

8a. When operator replies (happy path)

FreeScout sends the outbound reply email by default. Raptor does not send a separate notification — this would be double-notification. FreeScout's outbound email is customized (§11 / S8 sub-card) to: - Show "Raxx Support" as the sender name, no-reply@raxx.app as the From address. - Include a deep-link button: "View your ticket → https://support.raxx.app/tickets/{id}" - Carry Confidence Engine visual styling (dark brand, Antler accent colors). - Never mention FreeScout, Tagras, or any vendor name.

8b. In-portal notification badge

A ticket list entry gets a visual unread indicator when FreeScout's lastReplyFrom field is "user" (i.e., the last reply was from the support team, not the customer). Raptor maps this to "unread": true on the ticket list response.

No push notifications. No SMS. Email is the sole out-of-portal notification channel per the platform invariant.

8c. FreeScout webhook for real-time portal refresh

At initial launch, the portal polls on page focus (re-fetches the ticket list when the tab gains focus). A FreeScout webhook → Raptor → SSE/WebSocket push is a post-launch enhancement — it is out of scope for the portal launch window.


9. FreeScout Outbound Webhook (Raptor Ingestion)

FreeScout fires webhooks on conversation events. Raptor needs to receive these for two purposes: 1. Trigger the support_pending_submissions retry when FreeScout comes back online. 2. Future: real-time portal notification push.

Webhook endpoint: POST /api/webhooks/freescout/support

This is a separate endpoint from the existing POST /api/webhooks/freescout used by the status page (§5d of status-raxx-app.md). The routing is by the event type and presence of the type: customer (vs. type: note) thread field.

Signature verification: FreeScout HMAC-SHA256 via X-FreeScout-Signature. Same pattern as the status webhook. Secret stored in Infisical at /raxx/prod/freescout-support-webhook-secret.

Events handled at launch:

Event Action
conversation.status_changed to active Retry any pending submissions for this customer
conversation.created Log to support_audit_log (source: freescout_webhook)
All others No-op; return HTTP 200

10. Sequence Diagrams

10a. Happy path: customer opens and views a ticket

sequenceDiagram
    actor Customer
    participant SPA as support.raxx.app
    participant Raptor as api.raxx.app/api/v1/support
    participant FS as tickets.raxx.app (FreeScout)

    Customer->>SPA: Sign in (WebAuthn assertion)
    SPA->>Raptor: POST /api/sessions {assertion}
    Raptor-->>SPA: {jwt}
    SPA->>Raptor: GET /api/v1/support/tickets [Bearer jwt]
    Raptor->>FS: GET /api/conversations?customer[email]=cust@example.com
    FS-->>Raptor: [{id: 4821, subject: "...", status: "active", ...}]
    Raptor->>Raptor: Strip operator fields; map status
    Raptor-->>SPA: [{id: "4821", subject: "...", status: "open", unread: true}]
    SPA->>Customer: Render ticket list
    Customer->>SPA: Clicks ticket #4821
    SPA->>Raptor: GET /api/v1/support/tickets/4821
    Raptor->>FS: GET /api/conversations/4821 [verify email ownership]
    FS-->>Raptor: {customer: {email: "cust@example.com"}, ...}
    Raptor->>Raptor: email == jwt.customer_email ✓
    Raptor->>FS: GET /api/conversations/4821/threads
    FS-->>Raptor: [{type: "customer", body: "..."}, {type: "note", body: "..."}, {type: "message", body: "..."}]
    Raptor->>Raptor: Strip type:note threads
    Raptor-->>SPA: {threads: [{from: "customer", ...}, {from: "support", ...}]}
    SPA->>Customer: Render ticket thread

10b. Privacy boundary enforcement — cross-customer attempt

sequenceDiagram
    actor Attacker as Attacker (logged in as Customer A)
    participant SPA as support.raxx.app
    participant Raptor as api.raxx.app/api/v1/support
    participant FS as tickets.raxx.app (FreeScout)

    Attacker->>SPA: GET /api/v1/support/tickets/9999 [Bearer jwt for customer_a@example.com]
    SPA->>Raptor: GET /api/v1/support/tickets/9999
    Raptor->>FS: GET /api/conversations/9999
    FS-->>Raptor: {customer: {email: "customer_b@example.com"}, ...}
    Raptor->>Raptor: customer_b@example.com ≠ jwt.customer_email (customer_a@example.com)
    Raptor->>Raptor: Write audit log: {action: ticket.read, error_code: privacy_violation}
    Raptor-->>SPA: HTTP 403 {"error": "not_found"}
    Note over Raptor: "not_found" — deliberately vague; does not confirm ticket exists

10c. FreeScout outage — new ticket queued

sequenceDiagram
    actor Customer
    participant SPA as support.raxx.app
    participant Raptor as api.raxx.app/api/v1/support
    participant FS as tickets.raxx.app (FreeScout — DOWN)
    participant Queue as support_pending_submissions

    Customer->>SPA: Submit new ticket form
    SPA->>Raptor: POST /api/v1/support/tickets {subject, body}
    Raptor->>FS: POST /api/conversations
    FS-->>Raptor: Connection error / timeout
    Raptor->>Queue: INSERT pending_submission {customer_id, subject, body}
    Raptor-->>SPA: HTTP 202 {"status": "queued", "message": "..."}
    SPA->>Customer: "Your message was received and will be delivered shortly."
    Note over Queue: Retry job runs every 5 min
    Queue->>Raptor: Retry delivery (up to 24h)
    Raptor->>FS: POST /api/conversations [FreeScout back online]
    FS-->>Raptor: HTTP 201 {id: 4826, ...}
    Raptor->>Queue: UPDATE delivered_at = now()

11. Visual Identity

Per project_design_direction.md, Antlers direction is Confidence Engine (Direction C).

11a. Color palette

Tokens from frontend/trademaster_ui/src/styles/brand.css apply directly:

Usage Token Value
Page background --raxx-bg #0B0F14
Card / nav chrome --raxx-bg-2 #141A22
Hover / borders --raxx-bg-3 #1F2732
Primary text --raxx-fg #F7F8FA
Secondary text --raxx-muted #B8BEC7
Accent (CTAs, links) --raxx-moss-bright #7FB77E
Open ticket badge --raxx-warn #E5A23D
Resolved ticket badge --raxx-gain #3FB57F
Destructive / error --raxx-loss #E5484D

11b. Bandz-in-scenario states

Per the Confidence Engine direction, error and empty states use Bandz-in-scenario copy (honest, plain-spoken, no apology inflation). Examples:

Scenario Copy
No tickets yet "Nothing here yet — open a ticket and we'll get back to you."
Ticket not found (deleted) "This ticket is no longer available."
FreeScout down (queued) "Your message was received and will be delivered shortly."
Session expired "Your session timed out. Sign in again to continue."
Network error "Something went wrong loading your tickets. Try refreshing."

11c. Typography and layout


12. Migrations

12a. New tables (forward migration)

Add three new tables to backend_v2/db/migrations/: 1. NNNN_support_customer_map.sqlsupport_customer_map table (§4a) 2. NNNN_support_audit_log.sqlsupport_audit_log table (§4b) 3. NNNN_support_pending_submissions.sqlsupport_pending_submissions table (§4c)

The tables are new, non-breaking additions. No existing table is altered.

12b. Rollback

Drop all three tables. No data loss on rollback (all tables are new and contain no data that exists elsewhere). The FreeScout-side data (conversations, customers) is unaffected.

12c. FreeScout side

No schema changes on FreeScout. The support portal uses FreeScout's existing conversation and thread APIs. No custom fields are required (unlike the status page, which requires component_tag and incident_severity).


13. Rollout Plan

Phase Description Gate
Dark Raptor endpoints deployed + tables migrated. No CF Pages project yet. Manually verified via curl by operator. Dev review
Flag FLAG_SUPPORT_PORTAL_ENABLED gates all /api/v1/support/* routes. CF Pages project deployed to raxx-support-staging.pages.dev. Internal dogfood: Kristerpher authenticates, opens a test ticket
Beta support.raxx.app DNS live but not linked from app or marketing. Share URL with first 3–5 customers who have already submitted tickets via email. Gather feedback. No P1 bugs for 3 days; email notifications verified end-to-end
GA Link from raxx.app/support, app.raxx.app footer, and email footers. Announce in changelog. All #651 ACs pass; #707 email pipeline live

Dependency: The portal is only useful after #707 establishes the email pipeline (MX routing, first-ticket auto-reply, outbound branding). GA is gated on #707 completion.


14. Security Considerations

GDPR / Privacy checklist


15. Open Questions

These must be resolved before the indicated sub-cards can be claimed:

OQ-1 — Decision-1 from epic #651: Passkey-only vs. passkey + email backup at portal sign-in. The A+B recovery policy (confirmed 2026-04-30, #670) says customers enroll ≥2 devices at signup. Passkey-only is therefore the correct posture: every customer has ≥2 enrolled authenticators. The portal MUST follow the same passkey-only auth invariant as the rest of the platform. Recommendation: passkey-only. Close Decision-1 as resolved per the A+B policy. Blocking: S1, S5.

OQ-2 — Decision-2 from epic #651: Email intake (FreeScout native) AND portal, or portal-only? The design assumes both. Email intake is the v1.0-day-1 experience (#707). Disabling FreeScout email intake would break #707. Recommendation: both. This is not a blocker — the design supports both modes. But Kristerpher should confirm so S1 (customer mapping) handles the case where a FreeScout customer record exists before the customer ever uses the portal.

OQ-3 — GDPR DSR and FreeScout-side ticket deletion. When a customer requests erasure, Raptor deletes its tables via CASCADE. FreeScout conversations (which may contain PII in the ticket body) are not automatically deleted. The DSR runbook must document the manual FreeScout deletion step, and someone must perform it within the GDPR window (30 days). Options: (a) operator manual deletion, (b) Raptor-automated via FreeScout API on DSR trigger. Defer to attorney for guidance. Blocking: S1 (must note the gap in the migration spec) but not blocking S2–S9.

OQ-4 — FreeScout retention config. FreeScout defaults to unlimited retention. Recommend setting a 2-year conversation retention limit. Decision needed before #707 GA. Not blocking portal sub-cards but should be filed as a follow-up on #707.

OQ-5 — Category dropdown (Decision-4 from epic #651): optional category selector on new ticket form? The design spec (§5d) sends the category as a FreeScout tag. The question is whether the portal UI shows a dropdown at all. Recommendation: yes, optional dropdown with four values. If Kristerpher prefers free-form only, the category field is simply omitted from the POST body. Not blocking S1–S4 (backend doesn't care either way); blocking S7 (new ticket form UI).

OQ-6 — SUPPORT_MAILBOX_ID configuration. Raptor needs to know the FreeScout mailbox ID for the support mailbox to filter conversations correctly. This is a numeric ID assigned by FreeScout at mailbox creation time. It must be set as FREESCOUT_SUPPORT_MAILBOX_ID in the Heroku config before any endpoint works. Operator must retrieve this from FreeScout's admin UI after #707 lands. Not a design question — just a pre-launch ops task.