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