Skip to content
Closed
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
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"trigger:alerts": "bun scripts/trigger-alerts.ts"
},
"dependencies": {
"@ai-sdk/react": "^2.0.76",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
Expand Down Expand Up @@ -63,6 +64,7 @@
"@trpc/react": "^9",
"@trpc/server": "^9",
"@vercel/analytics": "^1.5.0",
"@vercel/functions": "^3.1.4",
"@xyflow/react": "^12.8.6",
"ai": "^5.0.76",
"ai-elements": "^1.1.2",
Expand Down
72 changes: 72 additions & 0 deletions scripts/send-email-to-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bun

import { getArgs } from "./utils/args";

function parseArgs() {
const args = getArgs();
const [userId, ...rest] = args;

if (!userId) {
console.error(
"Usage: bun scripts/send-email-to-user.ts <user-id> [--force] [--local]",
);
process.exit(1);
}

const config: { userId: string; force?: boolean; local?: boolean } = {
userId,
};

for (const option of rest) {
if (option === "--force") {
config.force = true;
} else if (option === "--local") {
config.local = true;
}
}

return config;
}

async function main() {
const { userId, force, local } = parseArgs();

const workerUrl = local ? "http://localhost:8787" : process.env.WORKER_URL;

if (!workerUrl) {
console.error(
"WORKER_URL is required unless --local is specified. Set WORKER_URL environment variable.",
);
process.exit(1);
}

const apiKey = process.env.WORKER_API_KEY;

const response = await fetch(`${workerUrl}/trigger/process-user-alerts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({ userId, forceSend: force }),
});

if (!response.ok) {
console.error(
`Failed to trigger workflow: ${response.status} ${response.statusText}`,
);
const text = await response.text();
console.error(text);
process.exit(1);
}

const result = await response.json();
console.log("Workflow triggered", result);
}

if (import.meta.main) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
151 changes: 74 additions & 77 deletions scripts/send-email.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type {
FlightOptionSummary,
NotificationEmailPayload,
} from "@/lib/notifications";
import { sendNotificationEmail } from "@/lib/notifications";
import {
generateDailyDigestBlueprint,
generatePriceDropBlueprint,
} from "@/lib/notifications/ai-email-agent";
import { buildDailyDigestBlueprintContext } from "@/lib/notifications/ai-email-context";
import { renderDailyPriceUpdateEmail } from "@/lib/notifications/templates/daily-price-update";
import { renderPriceDropAlertEmail } from "@/lib/notifications/templates/price-drop-alert";

const [recipientEmail, templateArg] = process.argv.slice(2);

Expand All @@ -15,77 +18,71 @@ if (!recipientEmail) {

const template = templateArg === "price-drop" ? "price-drop" : "daily";

const now = new Date();

const sampleFlight: FlightOptionSummary = {
totalPrice: 219,
currency: "USD",
slices: [
{
durationMinutes: 360,
stops: 0,
price: 219,
legs: [
{
airlineCode: "AA",
airlineName: "American Airlines",
flightNumber: "100",
departureAirportCode: "JFK",
departureAirportName: "John F. Kennedy International",
departureDateTime: now.toISOString(),
arrivalAirportCode: "LAX",
arrivalAirportName: "Los Angeles International",
arrivalDateTime: new Date(
now.getTime() + 5 * 60 * 60 * 1000,
).toISOString(),
durationMinutes: 300,
},
],
},
],
};

const baseAlert = {
id: "alt-demo",
label: "Weekend NYC → LA",
origin: "JFK",
destination: "LAX",
seatType: "Economy",
stops: "Nonstop",
airlines: ["AA"],
priceLimit: { amount: 250, currency: "USD" },
};

const payload: NotificationEmailPayload =
template === "price-drop"
? {
type: "price-drop-alert",
alert: baseAlert,
flights: [sampleFlight],
detectedAt: now.toISOString(),
previousLowestPrice: { amount: 299, currency: "USD" },
newLowestPrice: { amount: 219, currency: "USD" },
}
: {
type: "daily-price-update",
summaryDate: now.toISOString(),
alerts: [
{
alert: baseAlert,
flights: [sampleFlight],
generatedAt: now.toISOString(),
},
],
};

await sendNotificationEmail({
recipient: { email: recipientEmail },
payload,
})
.then((response) => {
console.log("Email queued:", response.data?.id);
})
.catch((error) => {
console.error("Failed to send email:", error);
process.exitCode = 1;
async function main() {
const baseAlert = {
id: "alt-demo",
label: "Weekend NYC → LA",
origin: "JFK",
destination: "LAX",
seatType: "Economy",
stops: "Nonstop",
airlines: ["AA"],
priceLimit: { amount: 250, currency: "USD" },
};

const now = new Date().toISOString();

if (template === "price-drop") {
const payload = {
type: "price-drop-alert" as const,
alert: baseAlert,
flights: [],
detectedAt: now,
};

const context = buildDailyDigestBlueprintContext({
type: "daily-price-update",
summaryDate: now,
alerts: [],
});

const blueprint = await generatePriceDropBlueprint({
alert: context.alerts[0]?.alert ?? baseAlert,
flights: [],
});

const email = renderPriceDropAlertEmail(payload, { blueprint });

await sendNotificationEmail({
recipient: { email: recipientEmail },
payload,
});

console.log("AI-generated price drop email sent to", recipientEmail);
console.log("Subject:", email.subject);
return;
}

const payload = {
type: "daily-price-update" as const,
summaryDate: now,
alerts: [],
};

const context = buildDailyDigestBlueprintContext(payload);
const blueprint = await generateDailyDigestBlueprint(context);
const email = renderDailyPriceUpdateEmail(payload, { blueprint });

await sendNotificationEmail({
recipient: { email: recipientEmail },
payload,
});

console.log("AI-generated daily digest email sent to", recipientEmail);
console.log("Subject:", email.subject);
}

await main().catch((error) => {
console.error("Failed to send email:", error);
process.exit(1);
});
3 changes: 3 additions & 0 deletions scripts/utils/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getArgs(): string[] {
return process.argv.slice(2);
}
3 changes: 3 additions & 0 deletions src/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./planner-agent";
export * from "./planner-prompt";
export * from "./types";
64 changes: 64 additions & 0 deletions src/ai/planner-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Experimental_Agent as Agent,
type InferUITools,
stepCountIs,
type UIDataTypes,
type UIMessage,
} from "ai";

import { getSystemPrompt } from "./planner-prompt";
import { controlSceneTool, searchDatesTool, searchFlightsTool } from "./tools";
import type { PlannerContext } from "./types";

const PlannerAgentTools = {
searchFlights: searchFlightsTool,
searchDates: searchDatesTool,
controlScene: controlSceneTool,
};

/**
* Create a Flight Planner Agent instance with user context.
*
* This factory function creates a new agent for each request with
* personalized system prompt including current date/time, user info,
* and scene state.
*
* Capabilities:
* - Search for specific flights with detailed filters
* - Find cheapest dates to fly across date ranges
* - Control UI scene to show maps or search results
* - Multi-turn conversations with context awareness
* - Parallel tool calls for efficient planning
*
* Tools:
* - searchFlights: Find one-way flights for specific dates
* - searchDates: Find best prices across date ranges
* - controlScene: Switch between map and search views
*
* Configuration:
* - Max steps: 15 (prevents infinite loops while allowing complex planning)
* - Auto tool choice: Agent decides when to use tools
* - Error recovery: Tools return graceful errors for agent to handle
*
* @param context - The planner context with user and scene information
* @returns A configured Agent instance
*/
export function createPlannerAgent(context: PlannerContext) {
return new Agent({
model: "openai/gpt-5-mini",
system: getSystemPrompt(context),
tools: PlannerAgentTools,
stopWhen: stepCountIs(25), // Allow multiple tool calls for complex planning
maxRetries: 3, // Retry on transient failures
});
}

/**
* Type-safe UI message for the planner agent.
* Includes all tool types with proper inference.
*/
export type PlannerAgentUIMessage = UIMessage<
never,
UIDataTypes,
InferUITools<typeof PlannerAgentTools>
>;
Loading
Loading