Skip to content

Commit ff47ece

Browse files
committed
feat: Final deployment updates with tracking notifications and shipping webhooks
1 parent c163b1c commit ff47ece

File tree

11 files changed

+510
-155
lines changed

11 files changed

+510
-155
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ SMTP_FROM=Your Store <support@your-domain.com>
2828

2929
# ── SHIPPO — Shipping Labels (Required for fulfillment) ──────────────────────
3030
SHIPPO_API_KEY=shippo_live_...
31+
SHIPPO_WEBHOOK_SECRET=your_token_here
3132

3233
# ── WAREHOUSE / SENDER ADDRESS (Required for Shippo label generation) ────────
3334
WAREHOUSE_NAME=Your Store Name

MASTER.sql

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ CREATE TABLE IF NOT EXISTS public.orders (
560560
'pending',
561561
'paid',
562562
'shipped',
563+
'out_for_delivery',
564+
'delivered',
563565
'cancelled',
564566
'refunded'
565567
)
@@ -569,10 +571,16 @@ CREATE TABLE IF NOT EXISTS public.orders (
569571
billing_address jsonb,
570572
stripe_session_id text UNIQUE,
571573
tracking_number text,
574+
carrier text,
575+
shippo_tracking_status text,
572576
metadata jsonb,
573577
created_at timestamptz NOT NULL DEFAULT now(),
574578
updated_at timestamptz NOT NULL DEFAULT now()
575579
);
580+
-- Ensure existing constraint is updated
581+
ALTER TABLE public.orders DROP CONSTRAINT IF EXISTS orders_status_check;
582+
ALTER TABLE public.orders ADD CONSTRAINT orders_status_check CHECK (status IN ('pending', 'paid', 'shipped', 'out_for_delivery', 'delivered', 'cancelled', 'refunded'));
583+
576584
ALTER TABLE public.orders
577585
ADD COLUMN IF NOT EXISTS user_id uuid;
578586

@@ -719,6 +727,17 @@ CREATE TABLE IF NOT EXISTS public.user_profiles (
719727
created_at timestamptz NOT NULL DEFAULT now(),
720728
updated_at timestamptz NOT NULL DEFAULT now()
721729
);
730+
-- 1L. order_tracking_history — real-time carrier updates
731+
CREATE TABLE IF NOT EXISTS public.order_tracking_history (
732+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
733+
order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,
734+
status text NOT NULL,
735+
details text,
736+
location text,
737+
shippo_event_id text UNIQUE,
738+
object_created timestamptz NOT NULL,
739+
created_at timestamptz NOT NULL DEFAULT now()
740+
);
722741
-- ───────────────────────────────────────────────────────────────
723742
-- §2 CMS TABLES
724743
-- ───────────────────────────────────────────────────────────────
@@ -817,6 +836,7 @@ ALTER TABLE public.navigation_menus ENABLE ROW LEVEL SECURITY;
817836
ALTER TABLE public.cms_pages ENABLE ROW LEVEL SECURITY;
818837
ALTER TABLE public.cms_sections ENABLE ROW LEVEL SECURITY;
819838
ALTER TABLE public.theme_settings ENABLE ROW LEVEL SECURITY;
839+
ALTER TABLE public.order_tracking_history ENABLE ROW LEVEL SECURITY;
820840
-- ───────────────────────────────────────────────────────────────
821841
-- §4 NUCLEAR DROP — every public RLS policy
822842
-- Guarantees zero duplicates and removes all stale/rogue
@@ -1255,6 +1275,26 @@ CREATE POLICY "cms_sections_delete" ON public.cms_sections FOR DELETE USING (
12551275
SELECT public.is_admin()
12561276
)
12571277
);
1278+
-- 5.21 ORDER_TRACKING_HISTORY — users see their own, admins see all
1279+
CREATE POLICY "order_tracking_history_select" ON public.order_tracking_history FOR SELECT USING (
1280+
EXISTS (
1281+
SELECT 1 FROM public.orders o
1282+
WHERE o.id = order_tracking_history.order_id
1283+
AND (
1284+
(SELECT auth.uid()) = o.user_id
1285+
OR (SELECT public.is_admin())
1286+
)
1287+
)
1288+
);
1289+
CREATE POLICY "order_tracking_history_insert" ON public.order_tracking_history FOR INSERT WITH CHECK (
1290+
(SELECT public.is_admin())
1291+
);
1292+
CREATE POLICY "order_tracking_history_update" ON public.order_tracking_history FOR UPDATE USING (
1293+
(SELECT public.is_admin())
1294+
);
1295+
CREATE POLICY "order_tracking_history_delete" ON public.order_tracking_history FOR DELETE USING (
1296+
(SELECT public.is_admin())
1297+
);
12581298
-- ───────────────────────────────────────────────────────────────
12591299
-- §6 TRIGGERS — updated_at + auto-profile on signup
12601300
-- ───────────────────────────────────────────────────────────────
@@ -1437,6 +1477,8 @@ CREATE INDEX IF NOT EXISTS idx_product_variants_sku ON public.product_variants(s
14371477
CREATE INDEX IF NOT EXISTS idx_profiles_role ON public.profiles(role);
14381478
-- Keep: is_admin() runs on every authenticated request via middleware.
14391479
CREATE INDEX IF NOT EXISTS idx_newsletter_email ON public.newsletter_subscribers(email);
1480+
-- Add index for tracking history
1481+
CREATE INDEX IF NOT EXISTS idx_tracking_order_id ON public.order_tracking_history(order_id);
14401482
-- Keep: uniqueness enforcement + duplicate-check on subscribe.
14411483
-- ───────────────────────────────────────────────────────────────
14421484
-- §9 SEED DATA (idempotent — ON CONFLICT DO UPDATE)

app/api/webhook/shippo/route.ts

Lines changed: 125 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,146 @@
1-
import { NextRequest, NextResponse } from "next/server";
2-
import { createClient } from "@/lib/supabase/admin";
3-
import {
4-
sendShippingNotificationEmail,
5-
sendDeliveryNotificationEmail,
6-
} from "@/lib/utils/email";
7-
8-
import type { OrderItem, OrderRecord } from "@/types/order";
9-
10-
// Incoming Shippo webhook body format
11-
interface ShippoTrackingStatus {
12-
status: string;
13-
status_details: string;
14-
status_date: string;
15-
}
16-
17-
interface ShippoWebhookBody {
18-
event: string;
19-
data: {
20-
tracking_number: string;
21-
tracking_status: ShippoTrackingStatus;
22-
carrier: string;
23-
};
24-
}
25-
26-
// Main webhook handler
27-
export async function POST(req: NextRequest) {
28-
try {
29-
const body = (await req.json()) as ShippoWebhookBody;
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createClient } from '@/lib/supabase/admin';
3+
import { verifyShippoSignature } from '@/lib/utils/shippo';
4+
import {
5+
sendShippingNotificationEmail,
6+
sendOutForDeliveryNotificationEmail,
7+
sendDeliveryNotificationEmail
8+
} from '@/lib/utils/email';
309

31-
// Only respond to track_updated events
32-
if (body.event !== "track_updated") {
33-
return NextResponse.json({ ignored: true });
10+
export async function POST(req: NextRequest) {
11+
const bodyText = await req.text();
12+
const signature = req.headers.get('x-shippo-signature') || '';
13+
14+
const searchParams = req.nextUrl.searchParams;
15+
const urlToken = searchParams.get('token');
16+
const secret = process.env.SHIPPO_WEBHOOK_SECRET;
17+
18+
// 1. Verify via URL Token (Method B) OR HMAC Signature (Method A)
19+
const isValid = (urlToken && urlToken === secret) || (await verifyShippoSignature(bodyText, signature));
20+
21+
if (!isValid) {
22+
console.error('[Shippo Webhook] Unauthorized access attempt');
23+
return NextResponse.json({ error: 'Invalid security token' }, { status: 401 });
3424
}
3525

36-
const { tracking_number, tracking_status } = body.data || {};
26+
const payload = JSON.parse(bodyText);
27+
const event = payload.event;
28+
const data = payload.data;
3729

38-
if (!tracking_number || !tracking_status) {
39-
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
30+
if (event !== 'track_updated') {
31+
return NextResponse.json({ message: 'Event ignored' });
4032
}
4133

42-
const supabase = await createClient();
43-
44-
// Idempotency: skip duplicates
45-
const eventId = `${tracking_number}_${tracking_status.status}_${tracking_status.status_date}`;
46-
47-
const { data: existing } = await supabase
48-
.from("shippo_events")
49-
.select("id")
50-
.eq("id", eventId)
51-
.maybeSingle();
34+
const trackingNumber = data.tracking_number;
35+
const carrier = data.carrier;
36+
const trackingStatus = data.tracking_status; // Latest status object
5237

53-
if (existing) {
54-
return NextResponse.json({ duplicate: true });
38+
if (!trackingNumber || !trackingStatus) {
39+
return NextResponse.json({ error: 'Missing tracking data' }, { status: 400 });
5540
}
5641

57-
// Map Shippo status → internal
58-
let newStatus = "shipped";
59-
60-
switch (tracking_status.status) {
61-
case "DELIVERED":
62-
newStatus = "delivered";
63-
break;
64-
case "RETURNED":
65-
newStatus = "returned";
66-
break;
67-
case "FAILURE":
68-
newStatus = "failed";
69-
break;
70-
case "OUT_FOR_DELIVERY":
71-
newStatus = "out_for_delivery";
72-
break;
73-
case "TRANSIT":
74-
case "PRE_TRANSIT":
75-
newStatus = "shipped";
76-
break;
77-
}
78-
79-
// Fetch order, including total_amount and items
80-
const { data: orderResponse, error: findError } = await supabase
81-
.from("orders")
82-
.select("id, customer_email, billing_address, fulfillment_status, total_amount, items")
83-
.eq("tracking_number", tracking_number)
84-
.maybeSingle();
42+
const supabase = await createClient();
8543

86-
const order = orderResponse as OrderRecord | null;
44+
// 2. Find Order
45+
const { data: order, error: orderError } = await supabase
46+
.from('orders')
47+
.select('*')
48+
.eq('tracking_number', trackingNumber)
49+
.maybeSingle();
8750

88-
if (findError || !order) {
89-
console.error("Order not found:", tracking_number);
90-
return NextResponse.json({ notFound: true });
51+
if (orderError || !order) {
52+
console.warn(`[Shippo Webhook] Order not found for tracking: ${trackingNumber}`);
53+
return NextResponse.json({ message: 'Order not found' });
9154
}
9255

93-
// Skip if no status change
94-
if (order.fulfillment_status === newStatus) {
95-
await supabase.from("shippo_events").insert({
96-
id: eventId,
97-
status: "skipped_duplicate_status",
98-
});
99-
100-
return NextResponse.json({ skipped: true });
56+
// 3. Update Order Status
57+
let nextStatus: string = order.status;
58+
let fulfillmentStatus: string = order.fulfillment_status;
59+
60+
// Map Shippo statuses to our Order statuses
61+
// SHippo statuses: UNKNOWN, PRE_TRANSIT, TRANSIT, OUT_FOR_DELIVERY, DELIVERED, RETURNED, FAILURE
62+
const shippoStatus = trackingStatus.status;
63+
64+
if (shippoStatus === 'DELIVERED') {
65+
nextStatus = 'delivered';
66+
fulfillmentStatus = 'delivered';
67+
} else if (shippoStatus === 'OUT_FOR_DELIVERY') {
68+
nextStatus = 'out_for_delivery';
69+
fulfillmentStatus = 'out_for_delivery';
70+
} else if (shippoStatus === 'TRANSIT') {
71+
// Only update to 'shipped' if it wasn't already 'out_for_delivery' or 'delivered'
72+
if (order.status !== 'delivered' && order.status !== 'out_for_delivery') {
73+
nextStatus = 'shipped';
74+
fulfillmentStatus = 'shipped';
75+
}
10176
}
10277

103-
// Update status & last tracking update
10478
const { error: updateError } = await supabase
105-
.from("orders")
106-
.update({
107-
fulfillment_status: newStatus,
108-
last_tracking_update: tracking_status.status_date,
109-
})
110-
.eq("id", order.id);
111-
112-
if (updateError) throw updateError;
113-
114-
const customerName = order.billing_address?.name || "Customer";
115-
116-
// Trigger controlled emails
117-
if (newStatus === "shipped") {
118-
const totalAmount =
119-
order.total_amount ??
120-
(order.items as OrderItem[])?.reduce((sum: number, item: OrderItem) => {
121-
return sum + item.price * item.quantity;
122-
}, 0) ??
123-
0;
124-
125-
await sendShippingNotificationEmail({
126-
customerEmail: order.customer_email,
127-
customerName,
128-
orderId: order.id,
129-
totalAmount,
130-
});
79+
.from('orders')
80+
.update({
81+
status: nextStatus as any,
82+
fulfillment_status: fulfillmentStatus as any,
83+
shippo_tracking_status: shippoStatus,
84+
carrier: carrier,
85+
updated_at: new Date().toISOString()
86+
})
87+
.eq('id', order.id);
88+
89+
if (updateError) {
90+
console.error('[Shippo Webhook] Update Order Error:', updateError);
91+
}
92+
93+
// 4. Record Tracking History
94+
// Shippo tracking status objects have an 'object_id' which is unique for that specific update
95+
const eventId = trackingStatus.object_id;
96+
97+
if (eventId) {
98+
await supabase
99+
.from('order_tracking_history')
100+
.upsert({
101+
order_id: order.id,
102+
status: shippoStatus,
103+
details: trackingStatus.status_details,
104+
location: trackingStatus.location
105+
? `${trackingStatus.location.city || ''}, ${trackingStatus.location.state || ''} ${trackingStatus.location.country || ''}`.trim().replace(/^,/, '').trim()
106+
: null,
107+
shippo_event_id: eventId,
108+
object_created: trackingStatus.object_created,
109+
}, { onConflict: 'shippo_event_id' });
131110
}
132111

133-
if (newStatus === "delivered") {
134-
const totalAmount =
135-
order.total_amount ??
136-
(order.items as OrderItem[])?.reduce((sum: number, item: OrderItem) => {
137-
return sum + item.price * item.quantity;
138-
}, 0) ??
139-
0;
140-
141-
await sendDeliveryNotificationEmail({
142-
customerEmail: order.customer_email,
143-
customerName,
144-
orderId: order.id,
145-
totalAmount,
146-
});
112+
// 5. Trigger Emails on State Changes
113+
if (nextStatus !== order.status) {
114+
const customerName = order.shipping_address?.name || 'Valued Customer';
115+
const customerEmail = order.customer_email || order.shipping_address?.email;
116+
117+
if (customerEmail) {
118+
if (nextStatus === 'shipped' && order.status === 'paid') {
119+
await sendShippingNotificationEmail({
120+
customerEmail,
121+
customerName,
122+
trackingNumber,
123+
orderId: order.id,
124+
totalAmount: order.amount_total
125+
});
126+
} else if (nextStatus === 'out_for_delivery') {
127+
await sendOutForDeliveryNotificationEmail({
128+
customerEmail,
129+
customerName,
130+
trackingNumber,
131+
orderId: order.id,
132+
totalAmount: order.amount_total
133+
});
134+
} else if (nextStatus === 'delivered') {
135+
await sendDeliveryNotificationEmail({
136+
customerEmail,
137+
customerName,
138+
orderId: order.id,
139+
totalAmount: order.amount_total
140+
});
141+
}
142+
}
147143
}
148144

149-
// Log event
150-
await supabase.from("shippo_events").insert({
151-
id: eventId,
152-
order_id: order.id,
153-
status: newStatus,
154-
});
155-
156-
return NextResponse.json({
157-
success: true,
158-
orderId: order.id,
159-
status: newStatus,
160-
});
161-
} catch (error: any) {
162-
console.error("Shippo webhook failed:", error);
163-
164-
return NextResponse.json(
165-
{ error: error.message },
166-
{ status: 500 }
167-
);
168-
}
145+
return NextResponse.json({ success: true });
169146
}

0 commit comments

Comments
 (0)