Code Lab: Life of an Orbital Kitchens Order¶
Tutorial for New Engineers
This code lab teaches you how to add a feature end-to-end: database → backend → frontend. Estimated time: 2-3 hours
All work in this tutorial is done in your local development environment only.
What You'll Learn¶
By the end of this tutorial, you'll understand:
- How to modify database schemas - Adding a new column to an existing table
- Backend architecture patterns - Router → Service → SQL layer separation
- API endpoint creation - Creating REST endpoints with FastAPI
- Frontend integration - Connecting React components to backend APIs
- Data flow - How data moves through the entire stack
Feature Overview¶
What we're building: A "Kitchen Notes" field that allows kitchen staff to add internal notes to orders (e.g., "Customer wants extra sauce", "Rush order", "Handle with care"). These notes are visible to all team members and can be edited.
Why this is a good learning example: It touches every layer of the stack without being too complex, and follows the exact patterns used throughout the codebase.
Prerequisites: Setting Up Your Local Development Environment¶
Before starting, you need to set up your local development environment. Follow these steps carefully:
Step 1: Verify Your Development Database Connection¶
-
Navigate to the backend directory:
-
Check your
.envfile (create one if it doesn't exist): -
Verify the database connection string:
- The connection should point to a development/test database
- If you're using Supabase, ensure you're using a development project (not production)
-
Ask your team lead if you're unsure which database to use
-
Test the connection (optional):
Step 2: Set Up Python Virtual Environment¶
It's recommended to use a Python virtual environment to isolate dependencies.
-
Create a virtual environment:
-
Activate the virtual environment:
You should see (venv) in your terminal prompt when it's activated.
- Install Python dependencies:
Note: Keep the virtual environment activated for all subsequent steps. If you open a new terminal, activate it again with source venv/bin/activate.
Step 3: Set Up the Backend Service¶
-
Navigate to the Order Management Service:
-
Start the backend service:
-
Verify it's running:
-
Keep this terminal open - the service will auto-reload when you make code changes.
Step 4: Set Up the Frontend¶
-
Navigate to the frontend directory:
-
Install dependencies (if not already done):
-
Start the development server:
-
Verify it's running:
- Open
http://localhost:3000in your browser -
The Google Auth only works on port 3000, so make sure the frontend runs on that port
-
Keep this terminal open - the frontend will hot-reload when you make changes.
Step 5: Check for Test Order Data¶
Before starting the tutorial, you need at least one order in your database to test with.
-
Check if orders exist in your database:
-
If you have orders (count > 0):
- You're all set! Proceed to the next step.
-
You can get an order ID with:
SELECT otter_order_id FROM otter.orders LIMIT 1; -
If you have no orders (count = 0):
- You'll need to create a test order. You can either:
Option A: Create a simple test order via SQL (Quick method):
-- Create a test order with minimal required fields
INSERT INTO otter.orders (
id,
otter_order_id,
store_id,
status,
friendly_id,
source,
created_at,
total,
customer_name
) VALUES (
gen_random_uuid(),
'test-order-' || to_char(now(), 'YYYYMMDDHH24MISS'),
'test-store-1',
'CONFIRMED',
'TEST-001',
'doordash',
NOW(),
10.99,
'Test Customer'
);
-- Verify it was created
SELECT otter_order_id FROM otter.orders WHERE source = 'doordash' LIMIT 1;
Option B: Send a test webhook (More realistic method):
- If your backend service is running, you can simulate an Otter webhook
- Ask your team for a sample webhook payload or check the test files
- Send it to: POST http://localhost:8001/webhooks/otter
Step 6: Access Your Development Database¶
For this tutorial, you'll need to run SQL queries. You can use either:
Option A: Supabase Dashboard (Recommended for beginners) 1. Navigate to your Supabase project dashboard 2. Click "SQL Editor" in the left sidebar 3. Create a new query and run SQL commands there
Option B: Command Line (psql)
Option C: Database GUI Tool
- Use DBeaver, pgAdmin, or TablePlus
- Connect using your POSTGRES_SUPABASE_URL connection string
Understanding the Architecture¶
Before we code, let's understand how the system is organized:
┌─────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ User clicks "Edit Note" → API call → Updates UI │
└──────────────────────┬──────────────────────────────────┘
│ HTTP Request
▼
┌─────────────────────────────────────────────────────────┐
│ Router (FastAPI Endpoint) │
│ /orders/{order_id}/kitchen-note │
│ - Validates request │
│ - Handles errors │
└──────────────────────┬──────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────┐
│ Service (Business Logic) │
│ OrderService.update_kitchen_note() │
│ - Contains business rules │
│ - Orchestrates data operations │
└──────────────────────┬──────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────┐
│ SQL Layer (Data Access) │
│ update_order_kitchen_note() │
│ - Executes SQL queries │
│ - Handles database connections │
└──────────────────────┬──────────────────────────────────┘
│ SQL Query
▼
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ otter.orders table │
└─────────────────────────────────────────────────────────┘
Key Pattern: Router → Service → SQL. This separation keeps code organized and testable.
Step 1: Add Database Column¶
First, we need to store the kitchen note in the database.
Understanding the Database Structure¶
Orders are stored in the otter.orders table in PostgreSQL. Let's check the current structure:
-
Open your database tool (Supabase SQL Editor, psql, or GUI tool)
-
Run this query to see the current columns:
This shows you all existing columns in the orders table.
Add the Kitchen Note Column¶
-
In your database tool, run this migration:
-
Verify the column was added:
-- Check that the column was added SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'otter' AND table_name = 'orders' AND column_name = 'kitchen_note'; -- Check existing orders (should show NULL for kitchen_note) SELECT id, otter_order_id, kitchen_note FROM otter.orders LIMIT 5;
You should see kitchen_note in the column list, and existing orders will have NULL for this field.
Why TEXT? Kitchen notes can be long, and TEXT allows unlimited length (unlike VARCHAR).
Checkpoint: ✅ Column kitchen_note added to otter.orders table.
Step 2: Update Backend Schema¶
Schemas define the structure of data. We use Pydantic schemas for validation and type safety.
Find the Schema File¶
File: orbital-manager-backend/order_management_service/app/schemas/order_schemas.py
Add the Field¶
-
Open the file in your code editor
-
Find the
OrderBaseclass (around line 10) and add the field:
Why OrderBase? This is the base schema that other order schemas inherit from, so adding it here makes it available everywhere.
Why str | None? The note is optional - orders might not have notes initially.
- Save the file - your backend service should auto-reload (if you started it with
--reload)
Checkpoint: ✅ Schema updated with kitchen_note field in OrderBase class.
Step 3: Create SQL Function¶
SQL functions handle all database operations. They're in the sql/ folder, separated by database type.
Find the SQL File¶
File: orbital-manager-backend/order_management_service/app/sql/postgres_sql/otter_order_sql.py
Add Update Function¶
-
Open the file in your code editor
-
Find the
update_order_by_idfunction (around line 280) -
Add this new function after it:
async def update_order_kitchen_note( otter_order_id: str, kitchen_note: str | None, ) -> bool: """Update kitchen note for an order. Args: otter_order_id: Otter order ID (string identifier) kitchen_note: Note text (can be None to clear) Returns: True if update was successful, False if order not found Raises: RuntimeError: If database pool not initialized Exception: If update fails """ from shared.databases.postgres import get_async_postgres_pool from shared.utils.logger import logger pool = await get_async_postgres_pool() if not pool: raise RuntimeError("PostgreSQL pool not initialized") try: async with pool.acquire() as conn: result = await conn.execute( """ UPDATE otter.orders SET kitchen_note = $1 WHERE otter_order_id = $2 """, kitchen_note, otter_order_id, ) updated = result == "UPDATE 1" if updated: logger.info( "Kitchen note updated", otter_order_id=otter_order_id, has_note=kitchen_note is not None, ) else: logger.warning( "Order not found for kitchen note update", otter_order_id=otter_order_id, ) return updated except Exception as error: logger.error( "Error updating kitchen note", otter_order_id=otter_order_id, exc_info=error, ) raise
Key Points:
- Uses otter_order_id (string) not id (UUID) - this matches the pattern in update_order_by_id
- Returns bool to indicate success/failure
- Includes proper error handling and logging
Update get_active_orders Function¶
We also need to include kitchen_note when fetching orders.
-
Find the
get_active_ordersfunction (around line 467) -
Add to SELECT statement (around line 494, after
o.otter_order_id):query = """ SELECT o.id, o.otter_order_id, o.kitchen_note, -- Add this line o.store_id, o.status, o.friendly_id, o.source, o.created_at, o.total, o.delivery_fee, o.customer_name, s.brand_name, s.station_name, i.id as item_id, i.name as item_name, i.quantity as item_quantity, i.price as item_price FROM otter.orders o # ... rest of query -
Add to order dictionary (around line 534, in the
orders_dict[order_id]initialization, afterotter_order_id):orders_dict[order_id] = { "id": order_id, "otter_order_id": row["otter_order_id"], "kitchen_note": row["kitchen_note"], # Add this line "store_id": row["store_id"], "status": row["status"], "friendly_id": row["friendly_id"], "source": row["source"], "created_at": row["created_at"], "total": row["total"], "delivery_fee": row["delivery_fee"], "customer_name": row["customer_name"], "brand_name": row["brand_name"], "station_name": row["station_name"], "items": [], }
Checkpoint: ✅ SQL function added and get_active_orders includes kitchen_note in both SELECT and returned dictionary.
Step 4: Create Service Method¶
Services contain business logic and sit between routers and SQL functions.
Find the Service File¶
File: orbital-manager-backend/order_management_service/app/services/order_service.py
Add Service Method¶
-
Open the file in your code editor
-
Find the
OrderServiceclass and locate theget_order_summarymethod (around line 226) -
Add this method after
get_order_summary:async def update_kitchen_note( self, otter_order_id: str, kitchen_note: str | None, ) -> bool: """Update kitchen note for an order. Args: otter_order_id: Otter order ID to update kitchen_note: Note text (None to clear) Returns: True if update successful, False if order not found Raises: RuntimeError: If database pool not initialized Exception: If update fails """ from ..sql.postgres_sql.otter_order_sql import update_order_kitchen_note return await update_order_kitchen_note( otter_order_id=otter_order_id, kitchen_note=kitchen_note, )
Note: The import can be added at the top of the file with other imports, or inside the method as shown above.
Why a service method? Even though it's simple now, this layer allows us to add business logic later (e.g., validation, notifications, audit logging).
Checkpoint: ✅ Service method added to OrderService class.
Step 5: Create API Endpoint¶
Routers define the HTTP API endpoints. They handle requests, call services, and return responses.
Find the Router File¶
File: orbital-manager-backend/order_management_service/app/routers/order_router.py
Add the Endpoint¶
-
Open the file in your code editor
-
Add the request model at the top of the file (after the imports, around line 20):
-
Find where other endpoints are defined (around line 100, after
get_order_summary) -
Add this endpoint:
@router.put("/{otter_order_id}/kitchen-note") async def update_order_kitchen_note( otter_order_id: str, request: UpdateKitchenNoteRequest, ) -> dict[str, str]: """Update kitchen note for an order. Args: otter_order_id: Otter order ID (path parameter) request: Request body with kitchen_note Returns: Success message Raises: HTTPException: 404 if order not found, 500 on server error """ try: success = await _service.update_kitchen_note( otter_order_id=otter_order_id, kitchen_note=request.kitchen_note, ) if not success: raise HTTPException( status_code=404, detail=f"Order not found: {otter_order_id}" ) return {"message": "Kitchen note updated successfully"} except RuntimeError as runtime_error: logger.error( "Database pool not initialized", exc_info=runtime_error, ) raise HTTPException( status_code=503, detail="Database connection not available", ) from runtime_error except Exception as error: logger.error( "Error updating kitchen note", otter_order_id=otter_order_id, exc_info=error, ) raise HTTPException( status_code=500, detail="Failed to update kitchen note", ) from error
Key Points:
- Uses PUT method (idempotent - safe to call multiple times)
- Path parameter: /{otter_order_id}/kitchen-note
- Matches error handling pattern from other endpoints
- Returns proper HTTP status codes
Checkpoint: ✅ Endpoint added and follows the same pattern as other endpoints in the file.
Step 6: Test the Backend¶
Before moving to frontend, let's verify the backend works.
Get an Order ID for Testing¶
-
Run this query in your database tool to get an order ID:
-
If no orders are returned:
- Go back to Step 5 in the Prerequisites section to create a test order
-
Then return here to continue testing
-
Copy the
otter_order_idvalue - you'll use it in the next step
Test with curl¶
-
Open a new terminal (keep your backend service running in the other terminal)
-
Test updating a kitchen note (replace
YOUR_ORDER_IDwith the actual ID):
Expected response: {"message": "Kitchen note updated successfully"}
- Verify it was saved by checking the database:
You should see your note in the kitchen_note column.
-
Test clearing the note:
-
Verify it was cleared:
The kitchen_note should now be NULL.
Checkpoint: ✅ API successfully updates and clears notes in the database.
Step 7: Update Frontend Types¶
TypeScript types ensure type safety between frontend and backend.
Find the Types File¶
File: orbital/Orbital/Orbital.Web/src/types/order.ts (or similar - check your project structure)
Add the Field¶
-
Open the file in your code editor
-
Find the
Ordertype definition and add the field:
Checkpoint: ✅ TypeScript type includes kitchen_note.
Step 8: Create API Client Function¶
API client functions abstract HTTP calls and provide type safety.
Find or Create API File¶
File: orbital/Orbital/Orbital.Web/src/utils/api/orders.ts (or similar - check your project structure)
Add the Function¶
-
Open the file (create it if it doesn't exist)
-
Add the function:
import { axios_client } from "../axios_client"; export interface UpdateKitchenNoteRequest { kitchen_note: string | null; } export const updateOrderKitchenNote = async ( otterOrderId: string, kitchenNote: string | null ): Promise<void> => { await axios_client.put( `/order-service/orders/${otterOrderId}/kitchen-note`, { kitchen_note: kitchenNote } ); };
Note: The path /order-service/ is the nginx proxy path for the Order Management Service (port 8001). If your frontend connects directly to the service, use /orders/ instead. Check your frontend's API configuration to match the pattern.
Checkpoint: ✅ API client function matches your backend endpoint path.
Step 9: Update Frontend Component¶
Now we'll add UI to display and edit kitchen notes.
Find the Orders Component¶
File: orbital/Orbital/Orbital.Web/src/pages/database/orders/index.tsx (or similar)
Add State and Handlers¶
-
Add these imports at the top of the file:
import { useState } from "react"; import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, IconButton, Box, Typography, Tooltip } from "@mui/material"; import { Edit } from "@mui/icons-material"; import { updateOrderKitchenNote } from "src/utils/api/orders"; // Adjust path as needed import { useSnackbarStore } from "src/components/api_response_alert"; // Adjust path as needed -
Add state inside your component:
-
Add handler functions:
const handleEditNote = (order: Order) => { setSelectedOrder(order); setNoteText(order.kitchen_note || ""); setNoteDialogOpen(true); }; const handleSaveNote = async () => { if (!selectedOrder) return; try { await updateOrderKitchenNote( selectedOrder.otter_order_id || selectedOrder._id, // Use otter_order_id if available noteText || null ); snackbarStore.show("Kitchen note updated successfully"); setNoteDialogOpen(false); // Refresh the order list (adjust based on your data fetching) mutate(); // if using SWR/React Query // or refetch(); // if using other data fetching } catch (error) { snackbarStore.show("Failed to update kitchen note", "error"); console.error(error); } };
Add Column to Data Grid¶
If using Material-UI DataGrid, add a column:
-
Find your columns definition (usually
const columns: GridColDef[] = [...]) -
Add this column:
const columns: GridColDef[] = [ // ... existing columns ... { field: "kitchen_note", headerName: "Kitchen Note", width: 200, renderCell: (params: GridCellParams) => { const order = params.row as Order; return ( <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> {order.kitchen_note ? ( <Tooltip title={order.kitchen_note}> <Typography variant="body2" noWrap> {order.kitchen_note} </Typography> </Tooltip> ) : ( <Typography variant="body2" color="text.secondary"> No note </Typography> )} <IconButton size="small" onClick={(e) => { e.stopPropagation(); handleEditNote(order); }} > <Edit fontSize="small" /> </IconButton> </Box> ); }, }, ];
Add Dialog Component¶
-
Add this dialog to your component's return statement (before the closing tag):
<Dialog open={noteDialogOpen} onClose={() => setNoteDialogOpen(false)} maxWidth="sm" fullWidth > <DialogTitle>Edit Kitchen Note</DialogTitle> <DialogContent> <TextField autoFocus margin="dense" label="Kitchen Note" fullWidth multiline rows={4} value={noteText} onChange={(e) => setNoteText(e.target.value)} placeholder="Add special instructions or notes for this order..." /> </DialogContent> <DialogActions> <Button onClick={() => setNoteDialogOpen(false)}>Cancel</Button> <Button onClick={handleSaveNote} variant="contained"> Save </Button> </DialogActions> </Dialog> -
Save the file - your frontend should hot-reload automatically
-
Open your browser to
http://localhost:3000/database/orders(or your orders page route) -
Test the feature:
- You should see a "Kitchen Note" column in the orders table
- Click the edit icon to open the dialog
- Add a note and click Save
- The note should appear in the grid
Checkpoint: ✅ UI displays notes and allows editing via a dialog.
Step 10: Test the Complete Feature¶
Now let's verify the entire flow works end-to-end.
Start Services¶
Make sure both services are running:
-
Backend (Terminal 1):
-
Frontend (Terminal 2):
Test Flow¶
-
Navigate to Orders page:
http://localhost:3000/database/orders -
Find an order and click the edit icon in the Kitchen Note column
-
Add a note: "Customer wants extra sauce"
-
Click Save
-
Verify the note appears in the grid
-
Click edit again and clear the note
-
Verify it shows "No note"
Verify in Database¶
Run this query in your database tool to see all orders with notes:
Checkpoint: ✅ End-to-end flow works - UI → API → Database → UI.
Understanding the Complete Flow¶
Let's trace what happens when you add a note:
- User Action: User clicks edit icon →
handleEditNote()opens dialog - User Input: User types note →
noteTextstate updates - User Saves: User clicks Save →
handleSaveNote()called - API Call:
updateOrderKitchenNote()→ HTTP PUT to/orders/{id}/kitchen-note - Router: FastAPI receives request → validates → calls service
- Service:
OrderService.update_kitchen_note()→ calls SQL function - SQL:
update_order_kitchen_note()→ executes UPDATE query - Database: PostgreSQL updates row → returns success
- Response: Success flows back → Router → Service → API Client → Component
- UI Update:
mutate()refreshes data → note appears in grid
This pattern applies to any feature: Understand the flow, update each layer, test end-to-end.
Common Issues and Solutions¶
Issue: Note doesn't save¶
Check: - Is the API endpoint path correct? Check browser Network tab - Is the order ID format correct? (string vs UUID) - Are there CORS errors? Check browser console
Solution: Verify the API path matches your backend route and check the Network tab for the actual request/response.
Issue: Note doesn't appear after saving¶
Check:
- Did you call mutate() or refetch() to refresh the data?
- Is kitchen_note included in the SELECT query for orders?
- Is the frontend type updated?
Solution: Ensure data refresh is called after successful update, and verify the SQL query includes kitchen_note.
Issue: Database error¶
Check:
- Did you run the ALTER TABLE migration?
- Is the column name exactly kitchen_note (case-sensitive)?
- Is the database connection working? Check your .env file
Solution: 1. Re-run the migration SQL in your database 2. Verify the column exists using the SQL queries in Step 1 3. Check your backend logs for connection errors
Issue: Type errors in TypeScript¶
Check:
- Is the Order type updated with kitchen_note?
- Are you using the correct field name (kitchen_note not kitchenNote)?
Solution: Ensure TypeScript types match the backend schema exactly.
Issue: Backend service won't start¶
Check:
- Are all dependencies installed? Run pip install -r requirements.txt
- Is port 8001 already in use? Try a different port or stop the other service
- Are there syntax errors? Check the terminal output
Solution: Review error messages in the terminal and fix any import or syntax issues.
Issue: Frontend won't connect to backend¶
Check: - Is the backend running on port 8001? - Is the API base URL correct in your frontend config? - Are there CORS issues? Check browser console
Solution: Verify both services are running and the frontend API configuration points to http://localhost:8001.
What You Learned¶
- Database Schema Changes - How to add columns to existing tables
- Backend Architecture - Router → Service → SQL layer separation
- API Design - Creating RESTful endpoints with proper error handling
- Frontend Integration - Connecting React components to backend APIs
- Data Flow - Understanding how data moves through the stack
- Testing - How to verify each layer works correctly
Next Steps¶
Now that you understand the pattern, try these enhancements:
- Note History - Track who added notes and when
- Note Notifications - Alert when a note is added to an active order
- Note Search - Filter orders by note content
- Note Templates - Pre-defined notes like "Rush", "Allergy Alert"
- Rich Text - Support formatting in notes
- Note Permissions - Only certain roles can edit notes
Summary¶
You've successfully added a complete feature! This demonstrates:
- Database schema modification
- Backend API endpoint creation (Router → Service → SQL)
- Frontend UI component integration
- End-to-end data flow
- Error handling and validation
The pattern: Understand the data model → Update database → Create/update API endpoints → Build the UI → Test end-to-end.
Important: When you're ready to deploy to production: 1. Create a proper database migration (not direct SQL) 2. Submit your code changes via pull request 3. Code will be reviewed and tested before production deployment 4. Database migrations will be run in a controlled, safe manner
This same pattern applies to any feature you want to add. Always work locally first, then deploy through proper channels. Happy coding!