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
| Decision | Resolution | Rationale |
|---|---|---|
| Onboarding data ownership | New {env}-OnboardingTable DDB table with components/onboarding_service/ shared library | Avoids layering violations. Both Controller and Admin Lambda depend on the library. Pattern: components/merchandising_service/. |
| DDB schema | PK=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 accounts | Hardcoded 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 creation | Admin Lambda calls Controller's existing account creation APIs | Controller already handles Cognito + Stripe + DDB. Admin Lambda acts as a proxy for the SE. |
| Account → user linking | Modified 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 UI | Generalize 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 visibility | Console 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 aggregation | Admin 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
| Decision | Resolution |
|---|---|
| Index type options | Keys of create_index_config map in account's DEFAULT_CONFIGS record (e.g. ecommerce, fashion, text). |
| Custom model | Any index type can have a custom model ID override. "Custom" is not a separate type. |
| Staging/prod tier | Separate 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 timing | Index 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
| Decision | Resolution |
|---|---|
| Pixel pre-creation | Pixel account is pre-created at magic-link generation time (alongside the Marqo account). Known to be needed regardless of storefront type. |
| Pixel automation scope | Full 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 creation | Admin Lambda runs the Python script directly (no GitHub workflow trigger). Faster, no GitHub dependency for infra. |
| GitHub PAT | Stored in AWS Secrets Manager. Admin Lambda fetches at runtime. |
| GET /pixel/:id scope | Core status only for MVP: ID, status, customer name, type, associated indexes, events flowing. Skip ETL details and Parquet analysis. |
| Storefront type | Web vs mobile only for MVP. Platform selection (Shopify headless/embedded/Other) is future scope. |
SE Workflow
| Decision | Resolution |
|---|---|
| Admin form pre-fill | SE 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 submit | All actions fire immediately: index creation, team invites, pixel_id → index association. |
| SE checklist tasks | Mostly manual pixel script customization (customer-specific website.ts). SE marks customization "Done" → pixel status becomes CONFIGURED. |
Progressive Save
| Decision | Resolution |
|---|---|
| Save strategy | PATCH 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 implementation | Use 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
| Field | Value |
|---|---|
pk | ONBOARDING#{onboarding_id} |
sk | DETAILS |
gsi1pk | ONBOARDING_LIST |
gsi1sk | ISO 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_aton 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 fromsignup.py - Do NOT create a new account — create OWNER membership to the pre-created account
- Set
registered_at,registered_byon 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 tosubmitted
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:
OnboardingSignupViewreuses:cognito_service.email_signup()for Cognito user creationUserServiceDdb.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_idto 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:
- Welcome: Branded intro, customer name from prefill
- Signup: Email + password (Formik + Yup). On submit →
POST .../signup. On success → user is authenticated, wizard continues. - Pixel: Storefront type (web/mobile), show snippet for selected type
- Index: Name, type (dropdown from DEFAULT_CONFIGS keys), custom model override, collapseFields, dev/prod toggle
- Team: Editable invitee table (email, login method, role), pre-populated from prefill
- 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 steponboarding.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: booleanchecklistOpen: booleantoggleChecklist: () => voidchecklistData: 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 linkGET /api/v1/onboarding— List all (GSI1 query)GET /api/v1/onboarding/{id}— Detail with aggregated health checkPATCH /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), runmanage_customer_infrastructure.pyGET /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
| Risk | Severity | Mitigation |
|---|---|---|
| Controller signup refactor breaks existing auth | High | Separate view (OnboardingSignupView), not modifying existing signup.py. Integration tests. |
| Pants multi-resolve dependency | Medium | Follow merchandising_service pattern exactly. Verify BUILD rules. |
| Unauthenticated endpoints abuse | Medium | UUID has 128-bit entropy. Rate limit. Expiry validation. Mark used after registration. |
| Pixel infra automation timeout | Medium | Lambda has 10-min timeout. Make idempotent. Consider async with status polling if needed. |
| Console public route guard | Low | Add /onboarding to publicPaths. Test that auth redirect doesn't trigger. |
beforeunload save reliability | Low | Use navigator.sendBeacon(). Accept both PATCH and POST. Debounce saves as safety net. |
Implementation Order
| Phase | Component | Depends On | Can Parallel With |
|---|---|---|---|
| 0 | Infra CDK | Nothing | — |
| 1 | onboarding_service | Phase 0 (table name) | — |
| 2 | Controller | Phase 1 | Phase 4, 5 |
| 3 | Console | Phase 2 | Phase 4, 5 |
| 4 | Admin Lambda | Phase 1 | Phase 2, 3 |
| 5 | Admin Worker | Phase 4 | Phase 2, 3 |
| 6 | E2E Tests | Phase 3, 5 | — |
Priority order: Controller first (most careful review needed), then Console, then Admin.