Initial commit

This commit is contained in:
Haitham Khalifa
2026-02-16 12:18:06 +01:00
commit b538d84e17
63 changed files with 15059 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAgentSettings, updateAgentSettings } from '@/lib/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const settings = await getAgentSettings(userId);
return NextResponse.json({ settings });
} catch (error) {
console.error('Error fetching agent settings:', error);
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
await updateAgentSettings(userId, body);
const settings = await getAgentSettings(userId);
return NextResponse.json({ settings });
} catch (error) {
console.error('Error updating agent settings:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBookedAppointments } from '@/lib/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const appointments = await getBookedAppointments(userId);
return NextResponse.json({ appointments });
} catch (error) {
console.error('Error fetching appointments:', error);
return NextResponse.json({ error: 'Failed to fetch appointments' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
import { app } from '@/lib/firebase';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: 'Missing email or password' },
{ status: 400 }
);
}
const auth = getAuth(app);
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const user = userCredential.user;
return NextResponse.json(
{
user: {
uid: user.uid,
email: user.email,
name: user.displayName,
},
},
{ status: 200 }
);
} catch (error: any) {
console.error('Login error:', error);
let message = 'Login failed';
if (error?.code === 'auth/user-not-found') {
message = 'No user found with that email';
} else if (error?.code === 'auth/wrong-password') {
message = 'Incorrect password';
} else if (error?.code === 'auth/invalid-credential') {
message = 'Invalid credentials';
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(_request: NextRequest) {
// MVP placeholder: always "not authenticated"
// Later we will read a token (cookie/header) and return the real user.
return NextResponse.json(
{
user: null,
isAuthenticated: false,
},
{ status: 200 }
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuth, createUserWithEmailAndPassword, updateProfile } from 'firebase/auth';
import { app } from '@/lib/firebase';
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json();
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Missing email, password or name' },
{ status: 400 }
);
}
const auth = getAuth(app);
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
if (auth.currentUser) {
await updateProfile(auth.currentUser, { displayName: name });
}
const user = userCredential.user;
// Firebase client SDK already keeps the session via cookies/local storage on client.
// For MVP, we just return basic user info.
return NextResponse.json(
{
user: {
uid: user.uid,
email: user.email,
name: user.displayName,
},
},
{ status: 201 }
);
} catch (error: any) {
console.error('Register error:', error);
const message =
error?.code === 'auth/email-already-in-use'
? 'Email already in use'
: 'Registration failed';
return NextResponse.json({ error: message }, { status: 400 });
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
export async function POST(req: NextRequest) {
try {
const { messages } = await req.json();
if (!process.env.GEMINI_API_KEY) {
return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 });
}
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const history = messages.slice(0, -1).map((msg: any) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
const userMessage = messages[messages.length - 1].content;
const chat = model.startChat({
history: history,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 1000,
},
});
const result = await chat.sendMessage(userMessage);
const text = result.response.text();
return NextResponse.json({ text });
} catch (error: any) {
console.error('Error in chat API:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDefaultLimits, updateDefaultLimits } from '@/lib/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const limits = await getDefaultLimits(userId);
return NextResponse.json({ limits });
} catch (error) {
console.error('Error fetching credit limits:', error);
return NextResponse.json({ error: 'Failed to fetch limits' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
await updateDefaultLimits(userId, body);
const limits = await getDefaultLimits(userId);
return NextResponse.json({ limits });
} catch (error) {
console.error('Error updating credit limits:', error);
return NextResponse.json({ error: 'Failed to update limits' }, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createCreditTransaction,
getUser,
updateUserCredits,
} from '@/lib/firestore';
import { Timestamp } from 'firebase/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const user = await getUser(userId);
return NextResponse.json({ balance: user?.creditBalance ?? 0 });
} catch (error) {
console.error('Error fetching credit balance:', error);
return NextResponse.json({ error: 'Failed to fetch balance' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const amount = Number(body?.amount);
if (!Number.isFinite(amount) || amount <= 0) {
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 });
}
const user = await getUser(userId);
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const newBalance = user.creditBalance + amount;
await updateUserCredits(userId, newBalance);
await createCreditTransaction({
userId,
amount,
type: 'purchase',
balanceAfter: newBalance,
createdAt: Timestamp.now(),
});
return NextResponse.json({ balance: newBalance });
} catch (error) {
console.error('Error adding credits:', error);
return NextResponse.json({ error: 'Failed to add credits' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { collection, getDocs, getFirestore, query, where, Timestamp } from 'firebase/firestore';
import { app } from '@/lib/firebase';
export async function GET(request: Request) {
// TODO: Secure this route using a secret header for Vercel Cron.
const db = getFirestore(app);
const now = Timestamp.now();
// TODO: Add index for scheduledAt + status.
const tasksQuery = query(
collection(db, 'callTasks'),
where('status', '==', 'pending'),
where('scheduledAt', '<=', now)
);
const snapshot = await getDocs(tasksQuery);
const origin = request.headers.get('origin') ?? 'http://localhost:3000';
for (const docSnap of snapshot.docs) {
const task = docSnap.data() as any;
// Trigger the call (TODO: include auth)
await fetch(`${origin}/api/vapi/create-call`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phoneNumber: task.phoneNumber,
userId: task.userId,
scheduledTaskId: docSnap.id,
}),
});
}
return NextResponse.json({ processed: snapshot.size });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getNotificationSettings,
updateNotificationSettings,
} from '@/lib/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const settings = await getNotificationSettings(userId);
return NextResponse.json({ settings });
} catch (error) {
console.error('Error fetching notification settings:', error);
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
await updateNotificationSettings(userId, body);
const settings = await getNotificationSettings(userId);
return NextResponse.json({ settings });
} catch (error) {
console.error('Error updating notification settings:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import {
deleteCallTask,
getCallTask,
updateCallTask,
} from '@/lib/firestore';
import { Timestamp } from 'firebase/firestore';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const task = await getCallTask(params.id);
if (!task || task.userId !== userId) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
console.error('Error fetching call task:', error);
return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const existing = await getCallTask(params.id);
if (!existing || existing.userId !== userId) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
const updates: Record<string, unknown> = { ...body };
if (updates.scheduledAt) {
updates.scheduledAt = Timestamp.fromDate(new Date(updates.scheduledAt as string));
}
await updateCallTask(params.id, updates);
const task = await getCallTask(params.id);
return NextResponse.json({ task });
} catch (error) {
console.error('Error updating call task:', error);
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const existing = await getCallTask(params.id);
if (!existing || existing.userId !== userId) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
await deleteCallTask(params.id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting call task:', error);
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCallTasks, createCallTask, getUser } from '@/lib/firestore';
import { Timestamp } from 'firebase/firestore';
export async function GET(request: NextRequest) {
try {
// TODO: Get userId from auth token
const userId = 'test-user-id'; // Replace with actual auth
const tasks = await getCallTasks(userId);
return NextResponse.json({ tasks });
} catch (error) {
console.error('Error fetching call tasks:', error);
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const userId = 'test-user-id'; // TODO: Get from auth token
const {
businessName,
phoneNumber,
preferredLanguage,
callGoals,
scheduledAt,
timezone,
maxMinutes,
allowRecalls,
maxRecalls,
} = body;
if (!businessName || !phoneNumber || !callGoals || !scheduledAt || !maxMinutes) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Validate user has enough credits
const user = await getUser(userId);
if (!user || user.creditBalance < maxMinutes) {
return NextResponse.json(
{ error: 'Insufficient credits' },
{ status: 400 }
);
}
const taskId = await createCallTask({
userId,
businessName,
phoneNumber,
preferredLanguage: preferredLanguage || 'English',
callGoals,
scheduledAt: Timestamp.fromDate(new Date(scheduledAt)),
timezone: timezone || undefined,
maxMinutes,
maxCredits: maxMinutes, // 1:1 for v1
allowRecalls: allowRecalls || false,
maxRecalls: maxRecalls || 0,
recallsUsed: 0,
status: 'pending',
createdAt: Timestamp.now(),
updatedAt: Timestamp.now(),
});
return NextResponse.json({ taskId });
} catch (error) {
console.error('Error creating call task:', error);
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const stripe = stripeSecretKey
? new Stripe(stripeSecretKey, { apiVersion: '2024-06-20' })
: null;
export async function POST(request: Request) {
try {
if (!stripe) {
return NextResponse.json({ error: 'Stripe is not configured.' }, { status: 500 });
}
const body = await request.json();
const credits = Number(body?.credits);
const amount = Number(body?.amount);
const userId = body?.userId as string | undefined;
// TODO: Get userId from auth token
const resolvedUserId = userId ?? 'test-user-id';
if (!resolvedUserId || !Number.isFinite(credits) || credits <= 0) {
return NextResponse.json({ error: 'Invalid checkout payload.' }, { status: 400 });
}
if (!Number.isFinite(amount) || amount <= 0) {
return NextResponse.json({ error: 'Invalid amount.' }, { status: 400 });
}
const origin = request.headers.get('origin') ?? 'http://localhost:3000';
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
quantity: 1,
price_data: {
currency: 'eur',
unit_amount: amount * 100,
product_data: {
name: `${credits} HolaCompi Credits`,
description: 'Pay-as-you-go voice call credits',
},
},
},
],
success_url: `${origin}/dashboard/credits?success=true`,
cancel_url: `${origin}/dashboard/credits?canceled=true`,
metadata: {
userId: resolvedUserId,
credits: credits.toString(),
amount: amount.toString(),
},
});
return NextResponse.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error('Stripe checkout error:', error);
return NextResponse.json({ error: 'Failed to create checkout session.' }, { status: 500 });
}
}
+76
View File
@@ -0,0 +1,76 @@
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { Timestamp } from 'firebase/firestore';
import {
createCreditTransaction,
createPurchase,
getUser,
updateUserCredits,
} from '@/lib/firestore';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const stripe = stripeSecretKey
? new Stripe(stripeSecretKey, { apiVersion: '2024-06-20' })
: null;
export async function POST(request: Request) {
if (!stripe || !webhookSecret) {
return NextResponse.json({ error: 'Stripe webhook not configured.' }, { status: 500 });
}
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing Stripe signature.' }, { status: 400 });
}
const payload = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (error) {
console.error('Stripe webhook signature error:', error);
return NextResponse.json({ error: 'Invalid signature.' }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const metadata = session.metadata ?? {};
const userId = metadata.userId;
const credits = Number(metadata.credits);
const amount = Number(metadata.amount);
if (!userId || !Number.isFinite(credits) || credits <= 0) {
return NextResponse.json({ error: 'Invalid checkout metadata.' }, { status: 400 });
}
const user = await getUser(userId);
if (!user) {
return NextResponse.json({ error: 'User not found.' }, { status: 404 });
}
const newBalance = user.creditBalance + credits;
await updateUserCredits(userId, newBalance);
await createCreditTransaction({
userId,
amount: credits,
type: 'purchase',
balanceAfter: newBalance,
createdAt: Timestamp.now(),
});
await createPurchase({
userId,
credits,
amount: Number.isFinite(amount) ? amount : credits,
currency: session.currency ?? 'eur',
stripeSessionId: session.id,
createdAt: Timestamp.now(),
});
}
return NextResponse.json({ received: true });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { createCallRecord, getAgentSettings, getUser } from '@/lib/firestore';
const VAPI_BASE_URL = 'https://api.vapi.ai';
const vapiPrivateKey = process.env.VAPI_PRIVATE_KEY;
export async function POST(request: Request) {
try {
if (!vapiPrivateKey) {
return NextResponse.json({ error: 'Vapi not configured.' }, { status: 500 });
}
const body = await request.json();
const phoneNumber = body?.phoneNumber as string | undefined;
const scheduledTaskId = body?.scheduledTaskId as string | undefined;
if (!phoneNumber) {
return NextResponse.json({ error: 'Phone number is required.' }, { status: 400 });
}
// TODO: Get userId from auth token
const userId = body?.userId ?? 'test-user-id';
const user = await getUser(userId);
if (!user || user.creditBalance < 1) {
return NextResponse.json({ error: 'Insufficient credits.' }, { status: 400 });
}
const agentSettings = await getAgentSettings(userId);
const assistantPayload = {
name: agentSettings?.agentName ?? 'HolaCompi',
instructions:
agentSettings?.agentInstructions ??
'You are HolaCompi, a helpful Spanish friend who makes phone calls on behalf of users.',
model: 'gpt-4o-mini',
voice: {
provider: 'openai',
voice: 'alloy',
},
language: agentSettings?.primaryCallLanguage ?? 'English',
};
// Create assistant dynamically based on agent settings
const assistantResponse = await fetch(`${VAPI_BASE_URL}/assistant`, {
method: 'POST',
headers: {
Authorization: `Bearer ${vapiPrivateKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(assistantPayload),
});
if (!assistantResponse.ok) {
const errorText = await assistantResponse.text();
return NextResponse.json({ error: errorText }, { status: 500 });
}
const assistant = await assistantResponse.json();
// Initiate outbound call via Vapi API
const callResponse = await fetch(`${VAPI_BASE_URL}/call`, {
method: 'POST',
headers: {
Authorization: `Bearer ${vapiPrivateKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
assistantId: assistant.id,
phoneNumber,
// TODO: Adjust payload based on Vapi call schema (customer/from numbers)
}),
});
if (!callResponse.ok) {
const errorText = await callResponse.text();
return NextResponse.json({ error: errorText }, { status: 500 });
}
const callData = await callResponse.json();
await createCallRecord({
userId,
phoneNumber,
status: 'initiated',
vapiCallId: callData.id,
assistantId: assistant.id,
scheduledTaskId,
});
return NextResponse.json({ status: callData.status ?? 'initiated', callId: callData.id });
} catch (error) {
console.error('Vapi create call error:', error);
return NextResponse.json({ error: 'Failed to create call.' }, { status: 500 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { Timestamp } from 'firebase/firestore';
import { getCallByVapiCallId, getUser, updateCallRecord, updateUserCredits } from '@/lib/firestore';
export async function POST(request: Request) {
try {
const payload = await request.json();
const eventType = payload?.event || payload?.type;
const callPayload = payload?.call || payload?.data?.call || payload;
const vapiCallId = callPayload?.id as string | undefined;
if (!vapiCallId) {
return NextResponse.json({ error: 'Missing call id.' }, { status: 400 });
}
const callRecord = await getCallByVapiCallId(vapiCallId);
if (!callRecord) {
return NextResponse.json({ error: 'Call record not found.' }, { status: 404 });
}
if (eventType === 'call-started' || eventType === 'call.started') {
await updateCallRecord(callRecord.id, {
status: 'started',
startedAt: Timestamp.now(),
});
}
if (eventType === 'call-failed' || eventType === 'call.failed') {
await updateCallRecord(callRecord.id, {
status: 'failed',
endedAt: Timestamp.now(),
});
}
if (eventType === 'call-ended' || eventType === 'call.ended') {
const durationSeconds =
Number(callPayload?.durationSeconds) ||
Number(callPayload?.duration) ||
0;
const creditsUsed = Math.ceil(durationSeconds / 60);
await updateCallRecord(callRecord.id, {
status: 'ended',
endedAt: Timestamp.now(),
durationSeconds,
creditsUsed,
recordingUrl: callPayload?.recordingUrl || callPayload?.recording?.url,
transcript: callPayload?.transcript || callPayload?.summary,
});
const user = await getUser(callRecord.userId);
if (user) {
const newBalance = Math.max(0, user.creditBalance - creditsUsed);
await updateUserCredits(callRecord.userId, newBalance);
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Vapi webhook error:', error);
return NextResponse.json({ error: 'Webhook processing failed.' }, { status: 500 });
}
}