.claude/skills/order-lifecycle-management/SKILL.md
Master order lifecycle and state management in RidenDine. Use when: (1) implementing order status updates, (2) debugging invalid state transitions, (3) handling role-based order operations, (4) building order tracking, (5) implementing cancellations. Key insight: Standardized lifecycle is draft → placed → accepted → preparing → ready → picked_up → out_for_delivery → delivered. Status enforcement via CHECK constraint + RLS policies.
npx skillsauth add Ritenoob/ridedine order-lifecycle-managementInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
RidenDine orders move through multiple states involving customers, chefs, and drivers. Each role can only perform specific status transitions. Database constraints enforce valid transitions. Payment status tracks separately from order fulfillment status.
Use this skill when:
Standardized Lifecycle (from Task 1 schema reconciliation):
draft → placed → accepted → preparing → ready → picked_up → out_for_delivery → delivered
↓
cancelled (can occur at any stage before delivered)
Status Definitions:
| Status | Actor | Meaning | Payment Required |
| -------------------- | -------- | -------------------------------------------- | ---------------- |
| draft | Customer | Cart/checkout in progress, not paid yet | No |
| placed | System | Payment succeeded, awaiting chef confirmation| Yes |
| accepted | Chef | Chef confirmed order, will prepare | Yes |
| preparing | Chef | Chef is cooking | Yes |
| ready | Chef | Food ready for pickup | Yes |
| picked_up | Driver | Driver collected from chef | Yes |
| out_for_delivery | Driver | En route to customer | Yes |
| delivered | Driver | Customer received order | Yes |
| cancelled | Any | Order cancelled (refund if paid) | Depends |
Database Constraint:
-- Migration: backend/supabase/migrations/20240107000000_schema_reconciliation.sql
ALTER TABLE orders ADD CONSTRAINT orders_status_check
CHECK (status IN ('draft', 'placed', 'accepted', 'preparing', 'ready', 'picked_up', 'out_for_delivery', 'delivered', 'cancelled'));
Separate from order status:
// orders table schema
interface Order {
id: string;
customer_id: string;
chef_id: string;
driver_id?: string;
status: OrderStatus; // Fulfillment status
payment_status: 'pending' | 'paid' | 'failed' | 'refunded'; // Payment status
total_cents: number;
created_at: string;
updated_at?: string;
}
Payment Status Lifecycle:
pending → paid (Stripe webhook confirms payment)
↓
failed (payment declined)
paid → refunded (order cancelled after payment)
Actor: Customer
Transition: None → draft
Location: apps/web/app/checkout/page.tsx
Flow:
placedExample Implementation:
// apps/web/app/checkout/actions.ts
'use server';
import { createActionClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function createDraftOrder(
cartItems: CartItem[],
chefId: string
) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error('User not authenticated');
}
// Calculate total (server-side validation)
const totalCents = cartItems.reduce(
(sum, item) => sum + item.price_cents * item.quantity,
0
);
// Create draft order
const { data: order, error } = await supabase
.from('orders')
.insert({
customer_id: user.id,
chef_id: chefId,
status: 'draft', // Entry status
payment_status: 'pending',
total_cents: totalCents,
})
.select()
.single();
if (error) throw error;
// Create order items
const { error: itemsError } = await supabase.from('order_items').insert(
cartItems.map((item) => ({
order_id: order.id,
dish_id: item.dish_id,
quantity: item.quantity,
price_cents: item.price_cents,
}))
);
if (itemsError) throw itemsError;
return order.id;
}
Actor: System (Stripe webhook)
Transition: draft → placed
Location: backend/supabase/functions/webhook_stripe/index.ts
Example Implementation:
// Webhook handler (see stripe-connect-marketplace skill for full implementation)
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.order_id;
if (orderId) {
// Update order status to "placed" (payment confirmed)
await supabase
.from('orders')
.update({
status: 'placed', // Ready for chef to accept
payment_status: 'paid',
})
.eq('id', orderId)
.eq('status', 'draft'); // Only update if still in draft status
// Send notification to chef (email, push notification, etc.)
}
break;
}
Actor: Chef
Transition: placed → accepted
Location: apps/web/app/chef/orders/actions.ts
Example Implementation:
'use server';
import { createActionClient } from '@/lib/supabase/server';
export async function acceptOrder(orderId: string) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Update order status
const { data, error } = await supabase
.from('orders')
.update({ status: 'accepted' })
.eq('id', orderId)
.eq('status', 'placed') // Only accept if currently placed
.select()
.single();
if (error) throw error;
// RLS policy ensures only order's chef can update (auth.uid() matches chef's profile_id)
// Send notification to customer
return data;
}
Frontend (Chef Dashboard):
// apps/web/app/chef/orders/page.tsx
'use client';
import { acceptOrder } from './actions';
export function OrderCard({ order }: { order: Order }) {
const handleAccept = async () => {
try {
await acceptOrder(order.id);
// Refresh page or update state
} catch (error) {
console.error('Failed to accept order:', error);
}
};
if (order.status === 'placed') {
return (
<div>
<h3>Order #{order.id.slice(0, 8)}</h3>
<p>Total: ${(order.total_cents / 100).toFixed(2)}</p>
<button onClick={handleAccept}>Accept Order</button>
</div>
);
}
return <div>Order {order.status}</div>;
}
Actor: Chef Transitions:
accepted → preparingpreparing → readyExample Implementation:
'use server';
export async function updateOrderStatus(
orderId: string,
newStatus: 'preparing' | 'ready'
) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Validate transition
const validTransitions: Record<string, string[]> = {
accepted: ['preparing', 'cancelled'],
preparing: ['ready', 'cancelled'],
};
// Fetch current status
const { data: order } = await supabase
.from('orders')
.select('status')
.eq('id', orderId)
.single();
if (!order) throw new Error('Order not found');
if (!validTransitions[order.status]?.includes(newStatus)) {
throw new Error(
`Invalid transition: ${order.status} → ${newStatus}`
);
}
// Update status
const { data, error } = await supabase
.from('orders')
.update({ status: newStatus })
.eq('id', orderId)
.select()
.single();
if (error) throw error;
// Notify driver if status is "ready" (assign driver)
return data;
}
Actor: Driver
Transition: ready → picked_up → out_for_delivery
Example Implementation:
'use server';
export async function pickupOrder(orderId: string) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Get driver record
const { data: driver } = await supabase
.from('drivers')
.select('id')
.eq('profile_id', user.id)
.single();
if (!driver) throw new Error('Not a driver');
// Update order: assign driver + change status
const { data, error } = await supabase
.from('orders')
.update({
driver_id: driver.id,
status: 'picked_up',
})
.eq('id', orderId)
.eq('status', 'ready') // Only pickup if ready
.select()
.single();
if (error) throw error;
// Notify customer
return data;
}
export async function startDelivery(orderId: string) {
const supabase = await createActionClient();
// Update status to out_for_delivery
const { data, error } = await supabase
.from('orders')
.update({ status: 'out_for_delivery' })
.eq('id', orderId)
.eq('status', 'picked_up')
.select()
.single();
if (error) throw error;
// Start GPS tracking (see ridendine-gps-tracking skill)
return data;
}
Actor: Driver
Transition: out_for_delivery → delivered
Example Implementation:
'use server';
export async function completeDelivery(orderId: string, proofPhotoUrl?: string) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Update order to delivered
const { data, error } = await supabase
.from('orders')
.update({
status: 'delivered',
delivered_at: new Date().toISOString(),
delivery_proof_url: proofPhotoUrl,
})
.eq('id', orderId)
.eq('status', 'out_for_delivery')
.select()
.single();
if (error) throw error;
// Stop GPS tracking
// Notify customer
// Trigger payout to chef (Stripe Connect)
return data;
}
Actor: Customer, Chef, or Admin
Transition: Any → cancelled
Rules:
acceptedpicked_uppicked_up, only admin can cancelpaidExample Implementation:
'use server';
export async function cancelOrder(orderId: string, reason: string) {
const supabase = await createActionClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Fetch order details
const { data: order } = await supabase
.from('orders')
.select('*, profiles!inner(role)')
.eq('id', orderId)
.single();
if (!order) throw new Error('Order not found');
// Check cancellation permissions
const userRole = order.profiles.role;
const canCancel =
(userRole === 'customer' && order.status === 'placed') ||
(userRole === 'chef' && ['placed', 'accepted', 'preparing', 'ready'].includes(order.status)) ||
userRole === 'admin';
if (!canCancel) {
throw new Error('Cannot cancel order at this stage');
}
// Update status
const { error } = await supabase
.from('orders')
.update({
status: 'cancelled',
cancelled_at: new Date().toISOString(),
cancellation_reason: reason,
})
.eq('id', orderId);
if (error) throw error;
// Issue refund if payment was made
if (order.payment_status === 'paid') {
// Call Stripe refund API (see stripe-connect-marketplace skill)
// Update payment_status to 'refunded'
}
// Notify all parties
return { success: true };
}
Customer sees live status updates:
Location: apps/web/app/orders/[orderId]/page.tsx
Example Implementation:
'use client';
import { createClient } from '@/lib/supabase/client';
import { useEffect, useState } from 'react';
export function OrderTracking({ orderId }: { orderId: string }) {
const [order, setOrder] = useState<Order | null>(null);
const supabase = createClient();
useEffect(() => {
// Fetch initial order
supabase
.from('orders')
.select('*')
.eq('id', orderId)
.single()
.then(({ data }) => setOrder(data));
// Subscribe to updates
const channel = supabase
.channel(`order-${orderId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'orders',
filter: `id=eq.${orderId}`,
},
(payload) => {
setOrder(payload.new as Order);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [orderId, supabase]);
if (!order) return <div>Loading...</div>;
return (
<div>
<h1>Order #{order.id.slice(0, 8)}</h1>
<OrderStatusTimeline status={order.status} />
</div>
);
}
function OrderStatusTimeline({ status }: { status: string }) {
const steps = [
{ key: 'placed', label: 'Order Placed' },
{ key: 'accepted', label: 'Chef Confirmed' },
{ key: 'preparing', label: 'Cooking' },
{ key: 'ready', label: 'Ready for Pickup' },
{ key: 'picked_up', label: 'Picked Up' },
{ key: 'out_for_delivery', label: 'Out for Delivery' },
{ key: 'delivered', label: 'Delivered' },
];
const currentIndex = steps.findIndex((s) => s.key === status);
return (
<ol>
{steps.map((step, index) => (
<li
key={step.key}
className={index <= currentIndex ? 'completed' : 'pending'}
>
{step.label}
</li>
))}
</ol>
);
}
Symptom: Database rejects status update
Cause: Attempting invalid transition or status typo
Fix:
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'orders'::regclass;draft → placed → accepted → ...Symptom: Payment succeeded but order still shows "draft"
Cause: Webhook not firing or processing error
Fix:
webhook_stripe Edge Function logs: supabase functions logs webhook_stripecheckout.session.completed event handledorder_id metadata passed to Stripe sessionSymptom: RLS policy blocks update
Cause: Chef's profile_id doesn't match order's chef_id
Fix:
orders table:
CREATE POLICY "Chefs can update own orders" ON orders
FOR UPDATE
USING (
auth.uid() IN (
SELECT profile_id FROM chefs WHERE id = orders.chef_id
)
);
chefs.profile_id matches authenticated user's IDSymptom: Web app shows "preparing", mobile shows "ready"
Cause: Cached data or missed real-time event
Fix:
channel.subscribe((status) => {
console.log('Subscription status:', status);
});
export const revalidate = 0; // Disable caching
Manual Testing Checklist:
From: backend/supabase/migrations/20240101000000_init.sql
-- Customers can view own orders
CREATE POLICY "Customers can view own orders" ON orders
FOR SELECT
USING (auth.uid() = customer_id);
-- Chefs can view assigned orders
CREATE POLICY "Chefs can view assigned orders" ON orders
FOR SELECT
USING (
auth.uid() IN (
SELECT profile_id FROM chefs WHERE id = orders.chef_id
)
);
-- Chefs can update own orders (status transitions)
CREATE POLICY "Chefs can update own orders" ON orders
FOR UPDATE
USING (
auth.uid() IN (
SELECT profile_id FROM chefs WHERE id = orders.chef_id
)
);
-- Drivers can view assigned deliveries
CREATE POLICY "Drivers can view assigned deliveries" ON orders
FOR SELECT
USING (
driver_id IS NOT NULL AND
auth.uid() IN (
SELECT profile_id FROM drivers WHERE id = orders.driver_id
)
);
-- Drivers can update assigned deliveries
CREATE POLICY "Drivers can update assigned deliveries" ON orders
FOR UPDATE
USING (
auth.uid() IN (
SELECT profile_id FROM drivers WHERE id = orders.driver_id
)
);
backend/supabase/migrations/20240107000000_schema_reconciliation.sqlbackend/supabase/functions/webhook_stripe/index.tsapps/web/app/orders/[orderId]/page.tsxsupabase-rls-patterns skilldevelopment
Integrate Coinbase crypto payments into payment systems. Use when: (1) adding crypto payment support, (2) building onchain features, (3) implementing wallet functionality. Covers Coinbase Commerce (payment processor) vs CDP (developer platform), Server Wallets, Embedded Wallets, and multi-network support.
development
Add Apple Pay and Google Pay to Stripe checkout. Use when: (1) adding mobile wallet payments, (2) improving mobile conversion, (3) implementing one-tap checkout. Stripe Payment Request Button automatically detects device capabilities and shows Apple Pay (Safari/iOS) or Google Pay (Chrome/Android).
development
Master Vercel deployment for RidenDine web and admin Next.js apps. Use when: (1) deploying to production, (2) configuring environment variables, (3) setting up preview deployments, (4) debugging build failures, (5) configuring domains, (6) seeing "No Next.js version detected" error in Vercel builds, (7) setting up monorepo with separate projects on free tier. Key insight: Vercel monorepos require Root Directory configuration via dashboard (not vercel.json), GitHub integration auto-detects monorepo structure, free tier allows multiple projects.
development
Master Supabase Row Level Security (RLS) for RidenDine. Use when: (1) adding new tables, (2) modifying RLS policies, (3) debugging access control issues, (4) role-based data access. Key insight: All tables use RLS with role-based policies from profiles.role column.