Drizzle schema in production, three stores seeded, and four dashboards talking to each other.
The old Supabase tables were dropped. A Drizzle schema with 19 tables was pushed to production. Three partner users, three stores, and 13 products were seeded. The customer app, partner dashboard, admin dashboard, and API server all read from the same live database. No mock data remains in the core order flow.
The customer app had store cards backed by a vendors table that no longer matched anything. The partner dashboard had const STORE_ID = 1 hardcoded at the top of the file. The API server was not connected to the database at all. None of the four apps were talking to each other.
The constraint
The monorepo had accumulated two separate data layers. The old Supabase schema — uuid-based vendors, menu_items, user_profiles, orders — was what the original codebase pointed at. The new Drizzle schema — integer-id users, partner_profiles, stores, products, orders — was defined in lib/db/src/schema/ but had never been pushed to the actual database.
The result was a codebase where every app was pointing at tables that either did not exist in production or contained no data.
Three specific problems needed to go in sequence:
- Push the Drizzle schema to Supabase without destroying the old tables before confirming they were empty.
- Seed the new tables with real partner accounts, stores, and products.
- Replace every hardcoded ID and mock array across all four apps with live API calls.
The proof
Pushing the schema
drizzle-kit push does not work on Windows. The command hangs on an interactive rename prompt when it detects a table that looks like a rename candidate, and the --config path fails because drizzle-kit rejects Windows backslashes.
I added .replace(/\\/g, "/") path normalization to lib/db/drizzle.config.ts and bypassed the interactive prompt entirely by running drizzle-kit generate followed by drizzle-kit migrate instead of push. That created the migration SQL files and applied them directly.
pnpm --filter @workspace/db drizzle-kit generate
pnpm --filter @workspace/db drizzle-kit migrate
The old vendors, menu_items, user_profiles, and uuid-based orders tables had zero rows. They were dropped. 19 new tables confirmed in Supabase.
The direct connection string (db.qhsocijsuogalvoahido.supabase.co:5432) has only an AAAA (IPv6) DNS record and this machine has no IPv6. I switched to the Supabase IPv4 Supavisor pooler:
postgresql://postgres.qhsocijsuogalvoahido:[password]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres
That is the connection string that works. The direct host does not.
Seeding
scripts/src/seed-stores.ts runs idempotently. It creates three partner users in the Drizzle users table, links each to a partner_profiles row, creates a stores row for each partner, and inserts products with onConflictDoNothing on the email and slug columns and existence checks on (storeId, name) pairs.
First run:
✅ Seeding complete.
Users: 3 total (3 partner accounts ensured)
Stores: 3 total (3 ensured)
Products: 13 total (13 created this run)
Second run confirming idempotency:
✅ Seeding complete.
Users: 3 total (3 partner accounts ensured)
Stores: 3 total (3 ensured)
Products: 13 total (0 created this run)
Three stores in production:
| Store | Category | Products |
|---|---|---|
| Kampala Grills | restaurant | 5 |
| Garden City Pharma | pharmacy | 4 |
| Uchumi Market | supermarket | 4 |
The API router
artifacts/api-server/src/routes/stores.ts was created and mounted at /stores in routes/index.ts. Seven endpoints:
GET /api/stores — active stores (is_open = true), optional ?category=
GET /api/stores/by-partner?email= — resolve partner's store ID from their email
GET /api/stores/user-by-email?email= — resolve customer's integer user ID from their email
GET /api/stores/:id — store by numeric id or slug
GET /api/stores/:id/products — full catalog; ?inStock=true for customer view
POST /api/stores/:id/products — add product
PATCH /api/stores/products/:productId — update inStock / price / stockQuantity
The by-partner and user-by-email routes are registered before the /:id wildcard. Express resolves routes in order — putting them after would have made both return 404 by matching :id = "by-partner".
Replacing hardcoded IDs across the apps
Customer app (fikalo-web):
HubPage.tsx previously fetched from the old partners Supabase table. It now calls GET /api/stores and maps category strings to vendor type labels:
const CATEGORY_TO_VENDOR: Record<string, Vendor["vendor_type"]> = {
restaurant: "FOOD",
pharmacy: "PHARMACY",
supermarket: "GROCERY",
};
StorePage.tsx fetches GET /api/stores/:id/products?inStock=true.
CheckoutPage.tsx had deliveryFee: 3000 hardcoded and customerId: 1 hardcoded in the order insert payload. Both are now fetched: deliveryFee from GET /api/stores/:id, customerId from GET /api/stores/user-by-email?email=.
Partner app (fikalo-partner):
DashboardPage.tsx had const STORE_ID = 1 at the top. It is gone. On mount, DashboardPage calls GET /api/stores/by-partner?email=${user.email} and sets storeId in state. That storeId is passed as a prop to both OrdersPanel and MenuPanel:
const [storeId, setStoreId] = useState<number>(1);
useEffect(() => {
if (!user?.email) return;
fetch(
`${API_BASE}/api/stores/by-partner?email=${encodeURIComponent(user.email)}`,
)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.ok && typeof data.storeId === "number")
setStoreId(data.storeId);
});
}, [user?.email]);
OrdersPanel uses storeId in the Supabase .eq("store_id", storeId) fetch and in the realtime filter store_id=eq.${storeId}. MenuPanel uses it in both the catalog fetch and the add-product POST.
Admin dashboard (fikalo-admin):
OverviewPage.tsx had its Live Activity feed seeded with two hardcoded placeholder events and no initial database query. I added an initial fetch of the 20 most recent orders from Supabase on mount:
supabase
.from("orders")
.select("*")
.order("created_at", { ascending: false })
.limit(20)
.then(({ data, error }) => {
if (!error && data && data.length > 0) {
const initialEvents = (data as Order[]).map((o) =>
orderToEvent(o, "INSERT"),
);
setDispatchEvents(initialEvents);
}
});
The realtime subscription still runs after, deduplicating by order ID so a status change does not append a duplicate row.
The database connection bug
The api-server/.env initially had the direct connection string. The server started, logged DATABASE_URL not set in environment, and returned 404 on every /api/stores call.
Two problems at once: the .env file was present but DATABASE_URL was unset because the variable name was correct but the server had been built before the file was edited and was running the cached binary. The fix was to stop the process, edit .env to use the pooler URL, and run pnpm run build && pnpm run start again.
[DB Warning] DATABASE_URL not set in environment.
That warning disappearing on restart confirmed the variable was loaded.
Typechecks — all four packages clean
pnpm --filter @workspace/api-server run typecheck # exit 0
pnpm --filter @workspace/fikalo-web run typecheck # exit 0
pnpm --filter @workspace/fikalo-partner run typecheck # exit 0
pnpm --filter @workspace/fikalo-admin run typecheck # exit 0
Where this sits
| Item | Status |
|---|---|
Old Supabase tables (vendors, menu_items, user_profiles) dropped | Done |
| 19-table Drizzle schema pushed to production | Done |
| 3 partner users, 3 stores, 13 products seeded (idempotent) | Done |
GET /api/stores — live store listing | Done |
GET /api/stores/by-partner — partner email → store ID | Done |
GET /api/stores/user-by-email — customer email → integer user ID | Done |
HubPage.tsx — live store cards, no mock data | Done |
StorePage.tsx — live product catalog | Done |
CheckoutPage.tsx — dynamic deliveryFee and customerId | Done |
DashboardPage.tsx — STORE_ID = 1 removed, resolved from email | Done |
OverviewPage.tsx — initial orders fetch on mount | Done |
| IPv4 pooler URL documented and in place | Done |
| Driver assignment wired to dispatch engine | Open |
The four apps are running against the same database. A customer places an order, the partner sees it within seconds, accepts it, and the admin dashboard shows the status change in the live feed.