Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/api/cart/customer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { supabase } from "../../../lib/supabase";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");

if (!userId) {
return NextResponse.json({ error: "userId required" }, { status: 400 });
}

try {
Comment on lines +5 to +12
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint accepts an arbitrary userId and will return/create a customerId for it. Combined with the cart/orders routes that also trust userId, this allows any caller to obtain another user’s customer record and then read/write their cart/orders. Require server-verified authentication and derive userId from the session instead of a query param (and avoid creating customer records for unauthenticated callers).

Suggested change
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "userId required" }, { status: 400 });
}
try {
const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const accessToken = authorization.slice("Bearer ".length).trim();
if (!accessToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(accessToken);
if (authError || !user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = user.id;

Copilot uses AI. Check for mistakes.
const { data: customer, error } = await supabase
.from("customers")
.select("id")
.eq("user_id", userId)
.single();

if (error && error.code !== "PGRST116") {
throw error;
}

if (customer?.id) {
return NextResponse.json({ customerId: customer.id });
}

const { data: newCustomer, error: createError } = await supabase
.from("customers")
.upsert([{ user_id: userId }], { onConflict: "user_id" })
.select("id")
.single();

if (createError) {
throw createError;
}

return NextResponse.json({ customerId: newCustomer?.id ?? null });
} catch (err) {
console.error("Error resolving customer:", err);
return NextResponse.json(
{
error: "Failed to resolve customer",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 }
);
}
}
50 changes: 43 additions & 7 deletions app/api/cart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ import { supabase } from "../../lib/supabase";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const customerId = searchParams.get("customerId");
const userId = searchParams.get("userId");

if (!customerId) {
return NextResponse.json({ error: "customerId required" }, { status: 400 });
if (!customerId || !userId) {
return NextResponse.json({ error: "customerId and userId required" }, { status: 400 });
}
Comment on lines 6 to 11
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cart endpoints authorize using customerId + userId values that are fully client-controlled query params/body fields. Without server-verified authentication, an attacker can enumerate userId/customerId pairs (especially via /api/cart/customer) and access or mutate other users’ carts. Fetch the authenticated user server-side (session/JWT cookie) and enforce ownership from that, rather than trusting userId from the request.

Copilot uses AI. Check for mistakes.

try {
const { data: customer, error: customerError } = await supabase
.from("customers")
.select("id")
.eq("id", customerId)
.eq("user_id", userId)
.maybeSingle();
if (customerError) throw customerError;
if (!customer) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { data, error } = await supabase
.from("carts")
.select("items")
Expand All @@ -33,19 +45,31 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const body = (await request.json()) as {
customerId?: string;
userId?: string;
restaurantId?: string;
items?: unknown[];
};
const { customerId, restaurantId, items } = body;
const { customerId, userId, restaurantId, items } = body;

if (!customerId || !restaurantId) {
if (!customerId || !userId || !restaurantId) {
return NextResponse.json(
{ error: "customerId and restaurantId required" },
{ error: "customerId, userId and restaurantId required" },
{ status: 400 }
);
}

try {
const { data: customer, error: customerError } = await supabase
.from("customers")
.select("id")
.eq("id", customerId)
.eq("user_id", userId)
.maybeSingle();
if (customerError) throw customerError;
if (!customer) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { error } = await supabase.from("carts").upsert(
{
customer_id: customerId,
Expand All @@ -71,12 +95,24 @@ export async function POST(request: Request) {
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const customerId = searchParams.get("customerId");
const userId = searchParams.get("userId");

if (!customerId) {
return NextResponse.json({ error: "customerId required" }, { status: 400 });
if (!customerId || !userId) {
return NextResponse.json({ error: "customerId and userId required" }, { status: 400 });
}

try {
const { data: customer, error: customerError } = await supabase
.from("customers")
.select("id")
.eq("id", customerId)
.eq("user_id", userId)
.maybeSingle();
if (customerError) throw customerError;
if (!customer) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { error } = await supabase
.from("carts")
.delete()
Expand Down
180 changes: 180 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,161 @@ interface Restaurant {
image: string;
}

type FoodSearchResult = {
name: string;
address?: string;
rating?: number;
latitude: number;
longitude: number;
distanceMiles: number;
};

function calculateDistanceMiles(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 3959;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}

function extractFoodQuery(message: string): string | null {
const text = message.toLowerCase();
const patterns = [
/(?:find|search|look for|show me|get me)\s+([a-z\s]+?)(?:\s+(?:near me|nearby|close|around here))?$/i,
/(?:any|where can i get|i want)\s+([a-z\s]+?)(?:\s+(?:near me|nearby|close|around here))?$/i,
/([a-z\s]+?)\s+(?:near me|nearby|close|around here)$/i,
];

for (const pattern of patterns) {
const match = message.match(pattern);
if (match?.[1]) {
return match[1].trim().toLowerCase();
}
}

if (text.includes("near me") || text.includes("nearby") || text.includes("close")) {
const words = text.replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
const stopWords = new Set([
"find",
"search",
"look",
"for",
"show",
"me",
"get",
"any",
"where",
"can",
"i",
"want",
"near",
"nearby",
"close",
"around",
"here",
"food",
"place",
"places",
"restaurant",
"restaurants",
]);
const filtered = words.filter((word) => !stopWords.has(word));
return filtered.length > 0 ? filtered.join(" ") : null;
}

return null;
}

function hasMenuMatches(menuData: Restaurant[], foodQuery: string): boolean {
const needle = foodQuery.toLowerCase();
return menuData.some(
(restaurant) =>
restaurant.cuisine?.toLowerCase().includes(needle) ||
restaurant.name.toLowerCase().includes(needle) ||
restaurant.menu.some((item) => item.name.toLowerCase().includes(needle))
);
}

async function searchGoogleFoodPlaces(
foodQuery: string,
latitude: number,
longitude: number
): Promise<FoodSearchResult[]> {
const apiKey = process.env.GOOGLE_MAPS_API_KEY || process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) return [];

const radii = [5000, 15000];
for (const radius of radii) {
const endpoint =
"https://maps.googleapis.com/maps/api/place/textsearch/json" +
`?query=${encodeURIComponent(`${foodQuery} restaurants`)}` +
`&location=${latitude},${longitude}` +
`&radius=${radius}` +
`&key=${apiKey}`;

const response = await fetch(endpoint, { cache: "no-store" });
if (!response.ok) continue;

const data = (await response.json()) as {
status?: string;
results?: Array<{
name?: string;
formatted_address?: string;
rating?: number;
geometry?: { location?: { lat?: number; lng?: number } };
}>;
};

if (data.status && !["OK", "ZERO_RESULTS"].includes(data.status)) continue;

const matches = (data.results || [])
.filter(
(result) =>
typeof result.name === "string" &&
typeof result.geometry?.location?.lat === "number" &&
typeof result.geometry?.location?.lng === "number"
)
.map((result) => {
const lat = result.geometry?.location?.lat as number;
const lon = result.geometry?.location?.lng as number;
return {
name: result.name as string,
address: result.formatted_address,
rating: result.rating,
latitude: lat,
longitude: lon,
distanceMiles: calculateDistanceMiles(latitude, longitude, lat, lon),
};
})
.sort((a, b) => a.distanceMiles - b.distanceMiles)
.slice(0, 5);

if (matches.length > 0) {
return matches;
}
}

return [];
}

export async function POST(request: Request) {
const body = (await request.json()) as {
message?: string;
menuData?: Restaurant[];
currentCart?: Array<{ name: string; quantity: number; price: number }>;
messages?: Array<{ role: string; content: string }>;
userLocation?: { latitude?: number; longitude?: number } | null;
};

const message = body.message?.trim();
Expand All @@ -48,6 +197,37 @@ export async function POST(request: Request) {
? body.currentCart.map((item) => `${item.quantity}x ${item.name}`).join(", ")
: "empty";

const foodQuery = extractFoodQuery(message);
const hasLocalFoodMatches =
foodQuery && menuData.length > 0 ? hasMenuMatches(menuData, foodQuery) : false;
const lat = body.userLocation?.latitude;
const lon = body.userLocation?.longitude;

if (
foodQuery &&
!hasLocalFoodMatches &&
typeof lat === "number" &&
typeof lon === "number"
) {
const nearbyMatches = await searchGoogleFoodPlaces(foodQuery, lat, lon);
if (nearbyMatches.length > 0) {
const lines = nearbyMatches.map((place, index) => {
const ratingText =
typeof place.rating === "number" ? ` • ⭐ ${place.rating.toFixed(1)}` : "";
const addressText = place.address ? ` • ${place.address}` : "";
return `${index + 1}. ${place.name} (${place.distanceMiles.toFixed(1)} mi${ratingText})${addressText}`;
});

return NextResponse.json({
reply:
`I could not find "${foodQuery}" in nearby QuickBite menus, so I rechecked your location and found these closest options:\n` +
`${lines.join("\n")}\n` +
"Tap a card to open directions, or ask me to find a similar item available in QuickBite.",
fallbackPlaces: nearbyMatches,
});
}
}

const systemPrompt = `You are a helpful food delivery assistant for QuickBite.

INSTRUCTIONS:
Expand Down
Loading
Loading