mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 13:30:20 -05:00
83 lines
3.9 KiB
TypeScript
83 lines
3.9 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { z } from "zod";
|
|
import type { SearchCriteria, Deal } from "@/lib/types";
|
|
import { fetchCityDeals } from "@/lib/providers/yowDeals";
|
|
import { buildSkyscannerLinks, buildGoogleFlightsLinks, buildAirCanadaLinks, buildAirTransatLinks } from "@/lib/providers/linkBuilders";
|
|
import { scoreDeal } from "@/lib/score";
|
|
|
|
const schema = z.object({
|
|
origin: z.string().min(3).max(4),
|
|
destinations: z.array(z.string().min(3)).nonempty(),
|
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
tripLengthMin: z.number().int().positive(),
|
|
tripLengthMax: z.number().int().positive(),
|
|
budget: z.number().int().positive().optional().nullable(),
|
|
currency: z.string().default("CAD").optional(),
|
|
nonStopOnly: z.boolean().optional(),
|
|
sources: z.array(z.string()).optional(),
|
|
preferences: z.object({
|
|
beach: z.number().min(0).max(10).optional(),
|
|
pool: z.number().min(0).max(10).optional(),
|
|
golf: z.number().min(0).max(10).optional(),
|
|
spa: z.number().min(0).max(10).optional(),
|
|
food: z.number().min(0).max(10).optional(),
|
|
nightlife: z.number().min(0).max(10).optional(),
|
|
shopping: z.number().min(0).max(10).optional(),
|
|
culture: z.number().min(0).max(10).optional(),
|
|
outdoor: z.number().min(0).max(10).optional(),
|
|
family: z.number().min(0).max(10).optional(),
|
|
}).optional()
|
|
});
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json();
|
|
const parsed = schema.parse(body) as SearchCriteria;
|
|
const sources = (parsed.sources && parsed.sources.length ? parsed.sources : ["Deals", "Skyscanner", "GoogleFlights", "AirCanada", "AirTransat"]).map(s => s.toLowerCase());
|
|
|
|
const tasks: Promise<Deal[]>[] = [];
|
|
|
|
if (sources.includes("deals")) tasks.push(fetchCityDeals(parsed));
|
|
if (sources.includes("skyscanner")) tasks.push(buildSkyscannerLinks(parsed));
|
|
if (sources.includes("googleflights")) tasks.push(buildGoogleFlightsLinks(parsed));
|
|
if (sources.includes("aircanada")) tasks.push(buildAirCanadaLinks(parsed));
|
|
if (sources.includes("airtransat")) tasks.push(buildAirTransatLinks(parsed));
|
|
|
|
// Demo mode fallback
|
|
const demo = process.env.DEMO === "true";
|
|
|
|
let results: Deal[] = [];
|
|
if (demo) {
|
|
results = [
|
|
{ id: "demo1", title: "🔥 Ottawa → Cancun (7 nights)", source: "Demo", link: "#", price: 685, currency: "CAD", startDate: parsed.startDate, endDate: parsed.startDate, nights: 7, origin: parsed.origin, destination: parsed.destinations[0], stops: 0 },
|
|
{ id: "demo2", title: "Ottawa → Punta Cana (7 nights)", source: "Demo", link: "#", price: 712, currency: "CAD", startDate: parsed.startDate, endDate: parsed.startDate, nights: 7, origin: parsed.origin, destination: parsed.destinations[0], stops: 1 }
|
|
];
|
|
} else {
|
|
const settled = await Promise.allSettled(tasks);
|
|
for (const s of settled) {
|
|
if (s.status === "fulfilled") results.push(...s.value);
|
|
}
|
|
}
|
|
|
|
// Filter and score
|
|
if (parsed.budget) results = results.filter(r => !r.price || r.price <= (parsed.budget || 0));
|
|
if (parsed.nonStopOnly) results = results.filter(r => r.stops == null || r.stops === 0);
|
|
|
|
results.forEach(r => r.score = scoreDeal(r, parsed.preferences));
|
|
results.sort((a,b) => (b.score || 0) - (a.score || 0));
|
|
|
|
// Optional n8n webhook
|
|
const webhook = process.env.N8N_WEBHOOK_URL;
|
|
if (webhook) {
|
|
try {
|
|
await fetch(webhook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ criteria: parsed, top10: results.slice(0,10) }) });
|
|
} catch(e) { /* ignore */ }
|
|
}
|
|
|
|
return new Response(JSON.stringify({ results }), { headers: { "content-type": "application/json" } });
|
|
} catch (e: any) {
|
|
return new Response(JSON.stringify({ error: e?.message || "Invalid payload" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
}
|
|
}
|