Skip to main content

Ecom Onboarding: Implementation Plan

Companion to ecom_onboarding.md (design doc). This document captures all resolved design decisions and provides an actionable implementation plan.

Resolved Design Decisions

These decisions were made during design review (2026-04-02) and are binding for implementation.

Architecture

DecisionResolutionRationale
Onboarding data ownershipNew {env}-OnboardingTable DDB table with components/onboarding_service/ shared libraryAvoids layering violations. Both Controller and Admin Lambda depend on the library. Pattern: components/merchandising_service/.
DDB schemaPK=ONBOARDING#{onboarding_id}, SK=DETAILS, GSI1 for listing (gsi1pk=ONBOARDING_LIST, gsi1sk=created_at)onboarding_id is a UUID that doubles as the magic link secret. GSI1 enables Admin UI listing sorted by recency.
Billing for pre-created accountsHardcoded real Airwallex card details in AWS Secrets Manager → env var. Used as placeholder Stripe token during account creation.Temporary until Stripe billing is removed entirely. Card has a low ($1) limit. Not exposed to customers.
Account creationAdmin Lambda calls Controller's existing account creation APIsController already handles Cognito + Stripe + DDB. Admin Lambda acts as a proxy for the SE.
Account → user linkingModified signup flow. Controller detects onboarding context, creates Cognito user, creates OWNER membership to pre-created account. No new account created.One API call from Console. Separate signup view to avoid regression risk on the core auth path.
Console auth flow/onboarding route works both unauthenticated (steps 1-2) and authenticated (steps 3+). Console checks onboarding state and resumes where user left off.Simplest UX. No redirect juggling.
Checklist UIGeneralize existing SlideDrawer from components/console/src/pages/merchandise/SlideDrawer.tsx. Right MUI drawer toggled from header.Existing pattern. Just needs to be lifted to app layout level.
Checklist visibilityConsole calls GET /onboarding/checklist for current account. If record exists → show button. If not → hide.No feature flag needed. Presence of onboarding record is the signal.
Health check aggregationAdmin Lambda aggregates status across Account, Team, API Keys, Index, Pixel dimensions. Console gets basic status from Controller.Admin Lambda already has cross-system access.

Index Configuration

DecisionResolution
Index type optionsKeys of create_index_config map in account's DEFAULT_CONFIGS record (e.g. ecommerce, fashion, text).
Custom modelAny index type can have a custom model ID override. "Custom" is not a separate type.
Staging/prod tierSeparate dimension from index type. Staging = smaller/cheaper infra settings. Production = full-scale. Applied as overrides on top of the selected type's config.
Index creation timingIndex creation does NOT fire until the full onboarding form is complete and submitted. Index creation is slow, expensive, and config is immutable — customer might want to go back and change something.

Pixel

DecisionResolution
Pixel pre-creationPixel account is pre-created at magic-link generation time (alongside the Marqo account). Known to be needed regardless of storefront type.
Pixel automation scopeFull automation for MVP. POST /pixel creates pixel account, writes customer-index-data.ts, creates placeholder website.ts, runs manage_customer_infrastructure.py directly.
Pixel infra creationAdmin Lambda runs the Python script directly (no GitHub workflow trigger). Faster, no GitHub dependency for infra.
GitHub PATStored in AWS Secrets Manager. Admin Lambda fetches at runtime.
GET /pixel/:id scopeCore status only for MVP: ID, status, customer name, type, associated indexes, events flowing. Skip ETL details and Parquet analysis.
Storefront typeWeb vs mobile only for MVP. Platform selection (Shopify headless/embedded/Other) is future scope.

SE Workflow

DecisionResolution
Admin form pre-fillSE pre-fills known customer data: index type, custom model, collapseFields, invitees, etc. Account IDs are auto-generated on submit. Cell ID is auto-selected.
On customer submitAll actions fire immediately: index creation, team invites, pixel_id → index association.
SE checklist tasksMostly manual pixel script customization (customer-specific website.ts). SE marks customization "Done" → pixel status becomes CONFIGURED.

Progressive Save

DecisionResolution
Save strategyPATCH fires on every "Next" button click + attempt save on beforeunload. Mid-step data may be lost if browser closes, but previous steps are preserved.
beforeunload implementationUse navigator.sendBeacon() for reliability. Controller should accept both PATCH and POST for saves.

Component Topology

+-----------------------+
| {env}-OnboardingTable | (DDB)
+-----------+-----------+
|
+---------------+---------------+
| |
+----------v-----------+ +------------v-----------+
| onboarding_service | | infra/admin CDK stack |
| (shared Python lib) | | (table + IAM) |
+---+-------------+----+ +------------------------+
| |
+------v------+ +---v-----------+
| controller | | admin_lambda |
| (Django BFF)| | (FastAPI) |
+------+------+ +---+-----------+
| |
+------v------+ +---v-----------+
| console | | admin_worker |
| (React) | | (React Router)|
+-------------+ +---------------+

Phase 0: Infrastructure (OnboardingTable + IAM)

0.1 Add OnboardingTable to Admin CDK Stack

File: infra/admin/stacks/admin_stack.py

New DDB table following existing setup_forks_table pattern (~line 43-55):

table = ddb.Table(
self, table_name,
table_name=config.envify("OnboardingTable"),
partition_key=ddb.Attribute(name="pk", type=ddb.AttributeType.STRING),
sort_key=ddb.Attribute(name="sk", type=ddb.AttributeType.STRING),
billing_mode=ddb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
point_in_time_recovery=config.is_prod,
deletion_protection=config.is_prod,
)
table.add_global_secondary_index(
index_name="gsi1",
partition_key=ddb.Attribute(name="gsi1pk", type=ddb.AttributeType.STRING),
sort_key=ddb.Attribute(name="gsi1sk", type=ddb.AttributeType.STRING),
projection_type=ddb.ProjectionType.ALL,
)

Add table ARN + GSI to the ddb_arns list in setup_service_role. Add ONBOARDING_TABLE_NAME to Lambda env_vars.

0.2 Grant Controller access

File: infra/controller/stacks/cloud_controller_cdk_stack.py

Add {env}-OnboardingTable ARN and GSI ARN to Controller's DynamoDB IAM policy.

0.3 Secrets Manager entries

Create manually in each environment:

  • {env}/onboarding/airwallex-card-details — Stripe-compatible token for placeholder billing
  • {env}/onboarding/github-pat — GitHub PAT with pixel repo write access

Phase 1: Onboarding Service (Shared Python Library)

Pattern template: components/merchandising_service/

Directory structure

components/onboarding_service/
├── BUILD # __dependencies_rules__
└── onboarding_service/
├── BUILD # python_sources() + python_test_utils()
├── conftest.py # pytest_plugins, env vars
├── models.py # OnboardingRecord Pydantic model
├── data/
│ ├── BUILD # python_sources + python_tests
│ ├── onboarding_data_service.py # Abstract + DDB implementation
│ └── onboarding_data_service_test.py
└── testing/
├── BUILD # python_sources
└── fixtures.py # DDB fixtures + sample data

Data model

class OnboardingRecord(BaseModel):
model_config = ConfigDict(frozen=True)

onboarding_id: str # UUID, also magic link secret
created_at: datetime
created_by: str # SE email
cell_id: str
visible_account_id: str
system_account_id: str
pixel_id: str | None = None
prefill_state: dict | None = None # SE-provided prefill values
created_note: str | None = None # Internal SE note
valid_until: datetime # Magic link expiry (30 days)
visited_at: datetime | None = None # First visit timestamp
registered_at: datetime | None = None # Signup completion timestamp
registered_by: str | None = None # User ID of registrant
submitted_at: datetime | None = None # Final form submission timestamp
status: str = "pending" # pending | visited | registered | submitted | completed

# Progressive form fields (filled by customer)
account_name: str | None = None
index_name: str | None = None
index_type: str | None = None # Key from create_index_config
custom_model: str | None = None
collapse_fields: list[str] | None = None
index_tier: str | None = None # "staging" | "production"
storefront_type: str | None = None # "web" | "mobile"
team_invites: list[dict] | None = None # [{email, login_method, role}]

Service interface

class OnboardingDataService(ABC):
def create(self, record: OnboardingRecord) -> None
def get(self, onboarding_id: str) -> OnboardingRecord | None
def get_by_account(self, system_account_id: str) -> OnboardingRecord | None
def update(self, onboarding_id: str, updates: dict) -> None
def list_all(self) -> list[OnboardingRecord] # via GSI1

class OnboardingDataServiceDDB(OnboardingDataService):
def __init__(self, table_name: str, ddb: DynamoDBServiceResource): ...

DDB key design

FieldValue
pkONBOARDING#{onboarding_id}
skDETAILS
gsi1pkONBOARDING_LIST
gsi1skISO timestamp of created_at

For get_by_account, use a GSI2 with gsi2pk=ACCOUNT#{system_account_id}, gsi2sk=DETAILS. Or scan with filter — acceptable given low record volume.

BUILD dependency rules

# components/onboarding_service/BUILD
__dependencies_rules__(
("*",
"//components/onboarding_service/**",
"//components/service_utils/**",
"//3rdparty/**",
"!//components/**",
"*"),
)

Testing

pants test //components/onboarding_service:: — CRUD operations against moto-backed DDB.


Phase 2: Controller — Onboarding Endpoints

This is the most architecturally sensitive phase. New Django app in the Controller.

2.1 New Django app

Create components/controller/onboarding/:

onboarding/
├── __init__.py
├── urls.py
├── views/
│ ├── __init__.py
│ ├── onboarding_views.py # Unauthenticated: GET, PATCH, signup, submit
│ └── checklist_views.py # Authenticated: GET checklist
├── serializers.py
└── services/
├── __init__.py
└── onboarding_controller_service.py # Wraps onboarding_data_service

2.2 URL patterns

# onboarding/urls.py
urlpatterns = [
path("/<str:onboarding_id>", OnboardingView.as_view(), name="get_onboarding"), # GET
path("/<str:onboarding_id>/save", SaveProgressView.as_view(), name="save_progress"), # PATCH + POST
path("/<str:onboarding_id>/signup", OnboardingSignupView.as_view(), name="signup"), # POST
path("/<str:onboarding_id>/submit", SubmitOnboardingView.as_view(), name="submit"), # POST
path("/checklist", ChecklistView.as_view(), name="checklist"), # GET (authed)
]

Register in config/urls.py:

path("api/onboarding", include("onboarding.urls")),

2.3 Endpoint specifications

GET /api/onboarding/{id} (AllowAny):

  • Read OnboardingRecord from DDB
  • Validate: not expired (valid_until > now), not already submitted
  • Set visited_at on first visit
  • Return: prefill_state + progressive form fields + checklist status

PATCH /api/onboarding/{id}/save (AllowAny):

  • Validate onboarding_id exists and is not expired
  • Accept partial form fields, write to DDB
  • Also accept POST (for navigator.sendBeacon())

POST /api/onboarding/{id}/signup (AllowAny):

  • Validate onboarding_id
  • Create Cognito user via cognito_service.email_signup() — reuse helpers from signup.py
  • Do NOT create a new account — create OWNER membership to the pre-created account
  • Set registered_at, registered_by on the OnboardingRecord
  • Return auth token (same format as normal signup response)

POST /api/onboarding/{id}/submit (Authenticated):

  • Read final form state from OnboardingRecord
  • Fire: index creation (via ecom API), team invites (via member service), pixel_id→index association
  • Set submitted_at, update status to submitted

GET /api/onboarding/checklist (Authenticated):

  • Get current user's system_account_id
  • Query for OnboardingRecord matching that account
  • If exists: return checklist status items (Index, Pixel, Team dimensions)
  • If not: 404

2.4 Onboarding-aware signup (critical integration)

The existing signup.py tightly couples user creation with account creation. Create a separate view rather than modifying the existing signup flow:

  • OnboardingSignupView reuses:
    • cognito_service.email_signup() for Cognito user creation
    • UserServiceDdb.create_user() for DDB user record
    • Pre-registration actions (Slack, Salesforce)
  • But instead of AccountServiceDdb.create_account(), calls:
    • AccountServiceDdb.create_membership() linking user → pre-created account with role=OWNER
    • Sets user.last_account_id to the pre-created visible_account_id

2.5 Controller settings

Add ONBOARDING_TABLE_NAME = os.environ.get("ONBOARDING_TABLE_NAME", "") to Controller settings.

Testing

Django unit tests with mocked Cognito (moto) and moto-backed DDB. Integration tests for the onboarding signup flow specifically — this is the highest risk area.


Phase 3: Console — Onboarding Wizard + Checklist

3.1 Routing

App.tsx: Add /onboarding to public paths (no auth required to load the page).

config/common.ts: Add endpoints:

onboarding: {
get: (id: string) => `/api/onboarding/${id}`,
save: (id: string) => `/api/onboarding/${id}/save`,
signup: (id: string) => `/api/onboarding/${id}/signup`,
submit: (id: string) => `/api/onboarding/${id}/submit`,
checklist: "/api/onboarding/checklist",
},

3.2 Onboarding API module

console/src/api/onboarding/api.ts: Axios calls. Important: get, save, signup must bypass the auth interceptor (no Bearer token). Create a separate axios instance or pass headers explicitly.

3.3 Wizard page

console/src/pages/onboarding/OnboardingWizard.tsx

URL: console.marqo.ai/onboarding?key={onboarding_id}

Steps:

  1. Welcome: Branded intro, customer name from prefill
  2. Signup: Email + password (Formik + Yup). On submit → POST .../signup. On success → user is authenticated, wizard continues.
  3. Pixel: Storefront type (web/mobile), show snippet for selected type
  4. Index: Name, type (dropdown from DEFAULT_CONFIGS keys), custom model override, collapseFields, dev/prod toggle
  5. Team: Editable invitee table (email, login method, role), pre-populated from prefill
  6. Done: Summary, fires POST .../submit, confirmation

Each "Next" calls PATCH .../save. Register beforeunload listener with navigator.sendBeacon().

3.4 Form configs

console/src/form-configs/onboarding/:

  • onboarding.forms.ts — field configs per wizard step
  • onboarding.validators.ts — Yup schemas per step

Try declarative form pattern first; eject to raw Formik if too fiddly (per design doc guidance).

3.5 Checklist drawer

console/src/components/onboarding/ChecklistDrawer.tsx

Generalize SlideDrawer from pages/merchandise/SlideDrawer.tsx. Lift to app layout level. Content shows:

  • Index: CREATING → READY
  • Pixel: MISSING → PROVISIONING → PENDING → RECEIVING → READY → CONFIGURED
  • Team: PENDING → READY (all invitees accepted)

Each item: icon (spinner/check/warning) + label + link to docs.

3.6 Header button

components/dashboard-navbar/dashboard-navbar.component.tsx (~line 71-72):

Add checklist icon button between ThemeToggle and AccountButton. Hidden when no onboarding record exists for this account.

3.7 Onboarding context

console/src/contexts/onboarding-context.tsx:

  • hasOnboarding: boolean
  • checklistOpen: boolean
  • toggleChecklist: () => void
  • checklistData: ChecklistResponse | null

Fetched during useDashboardInit hook on dashboard mount.

Testing

Unit tests for form validation. E2E Playwright test for wizard flow.


Phase 4: Admin Lambda — Onboarding + Pixel API Routes

4.1 Dependencies

admin_lambda/dependencies.py:

def get_onboarding_service() -> OnboardingDataServiceDDB:
return OnboardingDataServiceDDB(
table_name=_table("ONBOARDING_TABLE_NAME").name,
ddb=_dynamo(),
)

4.2 Onboarding routes

admin_lambda/routes/onboarding_routes.py:

  • POST /api/v1/onboarding — Create onboarding: generate UUID, call Controller for account creation (with placeholder Airwallex card), run pixel infra automation, create OnboardingRecord, return magic link
  • GET /api/v1/onboarding — List all (GSI1 query)
  • GET /api/v1/onboarding/{id} — Detail with aggregated health check
  • PATCH /api/v1/onboarding/{id} — SE updates (notes, checklist marks)
  • DELETE /api/v1/onboarding/{id} — Revoke invitation

4.3 Pixel routes

admin_lambda/routes/pixel_routes.py:

  • POST /api/v1/pixel — Full automation: create pixel account, write GitHub files (PAT from Secrets Manager), run manage_customer_infrastructure.py
  • GET /api/v1/pixel/{id} — Core status: ID, status, customer name, type, associated indexes, events flowing

4.4 Onboarding admin service

admin_lambda/services/onboarding_admin_service.py:

Encapsulates:

  • Account creation via Controller API call
  • Pixel infra automation (subprocess call to manage_customer_infrastructure.py)
  • GitHub PAT operations (REST API calls to GitHub)
  • Health check aggregation (querying Account, Team, API Keys, Index, Pixel)

4.5 Route registration

admin_lambda/main.py:

app.include_router(onboarding_router, prefix="/api/v1/onboarding")
app.include_router(pixel_router, prefix="/api/v1/pixel")

Testing

Integration tests with TestClient, moto-backed DDB, mocked Controller API calls.


Phase 5: Admin Worker — Onboarding UI

5.1 Routes

admin_worker/app/routes.ts:

route("onboarding", "routes/onboarding.tsx"),
route("onboarding/:onboardingId", "routes/onboarding.$onboardingId.tsx"),

5.2 List page

admin_worker/app/routes/onboarding.tsx: DataTable with columns: created_at, customer ID, status, created_by, magic link age. "New Onboarding" button.

5.3 Detail page

admin_worker/app/routes/onboarding.$onboardingId.tsx: Magic link + expiry, record contents, customer checklist preview, SE checklist with manual pixel tasks.

5.4 Sidebar nav

admin_worker/app/components/layout/sidebar.tsx: Add "Onboarding" nav item.

Testing

Vitest component tests. E2E Playwright.


Phase 6: E2E Tests

tests/console/tests/onboarding.test.ts + Page Object at tests/console/src/pages/onboarding.ts.

See design doc Testing section for full happy-path scenario.


Risk Register

RiskSeverityMitigation
Controller signup refactor breaks existing authHighSeparate view (OnboardingSignupView), not modifying existing signup.py. Integration tests.
Pants multi-resolve dependencyMediumFollow merchandising_service pattern exactly. Verify BUILD rules.
Unauthenticated endpoints abuseMediumUUID has 128-bit entropy. Rate limit. Expiry validation. Mark used after registration.
Pixel infra automation timeoutMediumLambda has 10-min timeout. Make idempotent. Consider async with status polling if needed.
Console public route guardLowAdd /onboarding to publicPaths. Test that auth redirect doesn't trigger.
beforeunload save reliabilityLowUse navigator.sendBeacon(). Accept both PATCH and POST. Debounce saves as safety net.

Implementation Order

PhaseComponentDepends OnCan Parallel With
0Infra CDKNothing
1onboarding_servicePhase 0 (table name)
2ControllerPhase 1Phase 4, 5
3ConsolePhase 2Phase 4, 5
4Admin LambdaPhase 1Phase 2, 3
5Admin WorkerPhase 4Phase 2, 3
6E2E TestsPhase 3, 5

Priority order: Controller first (most careful review needed), then Console, then Admin.