Initial API scaffold: Elysia + Bun + Drizzle + BetterAuth + LangChain

This commit is contained in:
2026-01-27 02:43:11 +00:00
commit 06f1b4e548
18 changed files with 1807 additions and 0 deletions

194
src/routes/clients.ts Normal file
View File

@@ -0,0 +1,194 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients } from '../db/schema';
import { eq, and, ilike, or, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
// Validation schemas
const clientSchema = t.Object({
firstName: t.String({ minLength: 1 }),
lastName: t.String({ minLength: 1 }),
email: t.Optional(t.String({ format: 'email' })),
phone: t.Optional(t.String()),
street: t.Optional(t.String()),
city: t.Optional(t.String()),
state: t.Optional(t.String()),
zip: t.Optional(t.String()),
company: t.Optional(t.String()),
role: t.Optional(t.String()),
industry: t.Optional(t.String()),
birthday: t.Optional(t.String()), // ISO date string
anniversary: t.Optional(t.String()),
interests: t.Optional(t.Array(t.String())),
family: t.Optional(t.Object({
spouse: t.Optional(t.String()),
children: t.Optional(t.Array(t.String())),
})),
notes: t.Optional(t.String()),
tags: t.Optional(t.Array(t.String())),
});
const updateClientSchema = t.Partial(clientSchema);
export const clientRoutes = new Elysia({ prefix: '/clients' })
// List clients with optional search
.get('/', async ({ query, user }: { query: { search?: string; tag?: string }; user: User }) => {
let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id));
if (query.search) {
const searchTerm = `%${query.search}%`;
baseQuery = db.select().from(clients).where(
and(
eq(clients.userId, user.id),
or(
ilike(clients.firstName, searchTerm),
ilike(clients.lastName, searchTerm),
ilike(clients.company, searchTerm),
ilike(clients.email, searchTerm)
)
)
);
}
const results = await baseQuery.orderBy(clients.lastName, clients.firstName);
// Filter by tag in-memory if needed (JSONB filtering)
if (query.tag) {
return results.filter(c => c.tags?.includes(query.tag!));
}
return results;
}, {
query: t.Object({
search: t.Optional(t.String()),
tag: t.Optional(t.String()),
}),
})
// Get single client
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
return client;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Create client
.post('/', async ({ body, user }: { body: typeof clientSchema.static; user: User }) => {
const [client] = await db.insert(clients)
.values({
userId: user.id,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone,
street: body.street,
city: body.city,
state: body.state,
zip: body.zip,
company: body.company,
role: body.role,
industry: body.industry,
birthday: body.birthday ? new Date(body.birthday) : null,
anniversary: body.anniversary ? new Date(body.anniversary) : null,
interests: body.interests || [],
family: body.family,
notes: body.notes,
tags: body.tags || [],
})
.returning();
return client;
}, {
body: clientSchema,
})
// Update client
.put('/:id', async ({ params, body, user }: { params: { id: string }; body: typeof updateClientSchema.static; user: User }) => {
// Build update object, only including provided fields
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
};
if (body.firstName !== undefined) updateData.firstName = body.firstName;
if (body.lastName !== undefined) updateData.lastName = body.lastName;
if (body.email !== undefined) updateData.email = body.email;
if (body.phone !== undefined) updateData.phone = body.phone;
if (body.street !== undefined) updateData.street = body.street;
if (body.city !== undefined) updateData.city = body.city;
if (body.state !== undefined) updateData.state = body.state;
if (body.zip !== undefined) updateData.zip = body.zip;
if (body.company !== undefined) updateData.company = body.company;
if (body.role !== undefined) updateData.role = body.role;
if (body.industry !== undefined) updateData.industry = body.industry;
if (body.birthday !== undefined) updateData.birthday = body.birthday ? new Date(body.birthday) : null;
if (body.anniversary !== undefined) updateData.anniversary = body.anniversary ? new Date(body.anniversary) : null;
if (body.interests !== undefined) updateData.interests = body.interests;
if (body.family !== undefined) updateData.family = body.family;
if (body.notes !== undefined) updateData.notes = body.notes;
if (body.tags !== undefined) updateData.tags = body.tags;
const [client] = await db.update(clients)
.set(updateData)
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
.returning();
if (!client) {
throw new Error('Client not found');
}
return client;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
body: updateClientSchema,
})
// Delete client
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(clients)
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
.returning({ id: clients.id });
if (!deleted) {
throw new Error('Client not found');
}
return { success: true, id: deleted.id };
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Mark client as contacted
.post('/:id/contacted', async ({ params, user }: { params: { id: string }; user: User }) => {
const [client] = await db.update(clients)
.set({
lastContactedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
.returning();
if (!client) {
throw new Error('Client not found');
}
return client;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
});

269
src/routes/emails.ts Normal file
View File

@@ -0,0 +1,269 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, communications } from '../db/schema';
import { eq, and } from 'drizzle-orm';
import { generateEmail, generateSubject, generateBirthdayMessage, type AIProvider } from '../services/ai';
import { sendEmail } from '../services/email';
import type { User } from '../lib/auth';
export const emailRoutes = new Elysia({ prefix: '/emails' })
// Generate email for a client
.post('/generate', async ({ body, user }: {
body: {
clientId: string;
purpose: string;
provider?: AIProvider;
};
user: User;
}) => {
// Get client
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
// Generate email content
const content = await generateEmail({
advisorName: user.name,
clientName: client.firstName,
interests: client.interests || [],
notes: client.notes || '',
purpose: body.purpose,
provider: body.provider,
});
// Generate subject
const subject = await generateSubject(body.purpose, client.firstName, body.provider);
// Save as draft
const [communication] = await db.insert(communications)
.values({
userId: user.id,
clientId: client.id,
type: 'email',
subject,
content,
aiGenerated: true,
aiModel: body.provider || 'anthropic',
status: 'draft',
})
.returning();
return communication;
}, {
body: t.Object({
clientId: t.String({ format: 'uuid' }),
purpose: t.String({ minLength: 1 }),
provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])),
}),
})
// Generate birthday message
.post('/generate-birthday', async ({ body, user }: {
body: {
clientId: string;
provider?: AIProvider;
};
user: User;
}) => {
// Get client
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
// Calculate years as client
const yearsAsClient = Math.floor(
(Date.now() - new Date(client.createdAt).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
);
// Generate message
const content = await generateBirthdayMessage({
clientName: client.firstName,
yearsAsClient,
interests: client.interests || [],
provider: body.provider,
});
// Save as draft
const [communication] = await db.insert(communications)
.values({
userId: user.id,
clientId: client.id,
type: 'birthday',
subject: `Happy Birthday, ${client.firstName}!`,
content,
aiGenerated: true,
aiModel: body.provider || 'anthropic',
status: 'draft',
})
.returning();
return communication;
}, {
body: t.Object({
clientId: t.String({ format: 'uuid' }),
provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])),
}),
})
// List emails (drafts and sent)
.get('/', async ({ query, user }: {
query: { status?: string; clientId?: string };
user: User;
}) => {
let conditions = [eq(communications.userId, user.id)];
if (query.status) {
conditions.push(eq(communications.status, query.status));
}
if (query.clientId) {
conditions.push(eq(communications.clientId, query.clientId));
}
const results = await db.select()
.from(communications)
.where(and(...conditions))
.orderBy(communications.createdAt);
return results;
}, {
query: t.Object({
status: t.Optional(t.String()),
clientId: t.Optional(t.String({ format: 'uuid' })),
}),
})
// Get single email
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [email] = await db.select()
.from(communications)
.where(and(eq(communications.id, params.id), eq(communications.userId, user.id)))
.limit(1);
if (!email) {
throw new Error('Email not found');
}
return email;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Update email (edit draft)
.put('/:id', async ({ params, body, user }: {
params: { id: string };
body: { subject?: string; content?: string };
user: User;
}) => {
const updateData: Record<string, unknown> = {};
if (body.subject !== undefined) updateData.subject = body.subject;
if (body.content !== undefined) updateData.content = body.content;
const [email] = await db.update(communications)
.set(updateData)
.where(and(
eq(communications.id, params.id),
eq(communications.userId, user.id),
eq(communications.status, 'draft') // Can only edit drafts
))
.returning();
if (!email) {
throw new Error('Email not found or already sent');
}
return email;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
body: t.Object({
subject: t.Optional(t.String()),
content: t.Optional(t.String()),
}),
})
// Send email
.post('/:id/send', async ({ params, user }: { params: { id: string }; user: User }) => {
// Get email
const [email] = await db.select({
email: communications,
client: clients,
})
.from(communications)
.innerJoin(clients, eq(communications.clientId, clients.id))
.where(and(
eq(communications.id, params.id),
eq(communications.userId, user.id),
eq(communications.status, 'draft')
))
.limit(1);
if (!email) {
throw new Error('Email not found or already sent');
}
if (!email.client.email) {
throw new Error('Client has no email address');
}
// Send via Resend
await sendEmail({
to: email.client.email,
subject: email.email.subject || 'Message from your advisor',
content: email.email.content,
});
// Update status
const [updated] = await db.update(communications)
.set({
status: 'sent',
sentAt: new Date(),
})
.where(eq(communications.id, params.id))
.returning();
// Update client's last contacted
await db.update(clients)
.set({ lastContactedAt: new Date() })
.where(eq(clients.id, email.client.id));
return updated;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Delete draft
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(communications)
.where(and(
eq(communications.id, params.id),
eq(communications.userId, user.id),
eq(communications.status, 'draft') // Can only delete drafts
))
.returning({ id: communications.id });
if (!deleted) {
throw new Error('Email not found or already sent');
}
return { success: true, id: deleted.id };
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
});

281
src/routes/events.ts Normal file
View File

@@ -0,0 +1,281 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { events, clients } from '../db/schema';
import { eq, and, gte, lte, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const eventRoutes = new Elysia({ prefix: '/events' })
// List events with optional filters
.get('/', async ({ query, user }: {
query: {
clientId?: string;
type?: string;
upcoming?: string; // days ahead
};
user: User;
}) => {
let conditions = [eq(events.userId, user.id)];
if (query.clientId) {
conditions.push(eq(events.clientId, query.clientId));
}
if (query.type) {
conditions.push(eq(events.type, query.type));
}
let results = await db.select({
event: events,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(events)
.innerJoin(clients, eq(events.clientId, clients.id))
.where(and(...conditions))
.orderBy(events.date);
// Filter upcoming events if requested
if (query.upcoming) {
const daysAhead = parseInt(query.upcoming) || 7;
const now = new Date();
const future = new Date();
future.setDate(future.getDate() + daysAhead);
results = results.filter(r => {
const eventDate = new Date(r.event.date);
// For recurring events, check if the month/day falls within range
if (r.event.recurring) {
const thisYear = new Date(
now.getFullYear(),
eventDate.getMonth(),
eventDate.getDate()
);
const nextYear = new Date(
now.getFullYear() + 1,
eventDate.getMonth(),
eventDate.getDate()
);
return (thisYear >= now && thisYear <= future) ||
(nextYear >= now && nextYear <= future);
}
return eventDate >= now && eventDate <= future;
});
}
return results;
}, {
query: t.Object({
clientId: t.Optional(t.String({ format: 'uuid' })),
type: t.Optional(t.String()),
upcoming: t.Optional(t.String()),
}),
})
// Get single event
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [event] = await db.select({
event: events,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(events)
.innerJoin(clients, eq(events.clientId, clients.id))
.where(and(eq(events.id, params.id), eq(events.userId, user.id)))
.limit(1);
if (!event) {
throw new Error('Event not found');
}
return event;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Create event
.post('/', async ({ body, user }: {
body: {
clientId: string;
type: string;
title: string;
date: string;
recurring?: boolean;
reminderDays?: number;
};
user: User;
}) => {
// Verify client belongs to user
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, body.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
const [event] = await db.insert(events)
.values({
userId: user.id,
clientId: body.clientId,
type: body.type,
title: body.title,
date: new Date(body.date),
recurring: body.recurring ?? false,
reminderDays: body.reminderDays ?? 7,
})
.returning();
return event;
}, {
body: t.Object({
clientId: t.String({ format: 'uuid' }),
type: t.String({ minLength: 1 }),
title: t.String({ minLength: 1 }),
date: t.String(), // ISO date
recurring: t.Optional(t.Boolean()),
reminderDays: t.Optional(t.Number({ minimum: 0 })),
}),
})
// Update event
.put('/:id', async ({ params, body, user }: {
params: { id: string };
body: {
type?: string;
title?: string;
date?: string;
recurring?: boolean;
reminderDays?: number;
};
user: User;
}) => {
const updateData: Record<string, unknown> = {};
if (body.type !== undefined) updateData.type = body.type;
if (body.title !== undefined) updateData.title = body.title;
if (body.date !== undefined) updateData.date = new Date(body.date);
if (body.recurring !== undefined) updateData.recurring = body.recurring;
if (body.reminderDays !== undefined) updateData.reminderDays = body.reminderDays;
const [event] = await db.update(events)
.set(updateData)
.where(and(eq(events.id, params.id), eq(events.userId, user.id)))
.returning();
if (!event) {
throw new Error('Event not found');
}
return event;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
body: t.Object({
type: t.Optional(t.String()),
title: t.Optional(t.String()),
date: t.Optional(t.String()),
recurring: t.Optional(t.Boolean()),
reminderDays: t.Optional(t.Number()),
}),
})
// Delete event
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(events)
.where(and(eq(events.id, params.id), eq(events.userId, user.id)))
.returning({ id: events.id });
if (!deleted) {
throw new Error('Event not found');
}
return { success: true, id: deleted.id };
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
})
// Sync events from client birthdays/anniversaries
.post('/sync/:clientId', async ({ params, user }: { params: { clientId: string }; user: User }) => {
// Get client
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
const created = [];
// Create birthday event if client has birthday
if (client.birthday) {
// Check if birthday event already exists
const [existing] = await db.select()
.from(events)
.where(and(
eq(events.clientId, client.id),
eq(events.type, 'birthday')
))
.limit(1);
if (!existing) {
const [event] = await db.insert(events)
.values({
userId: user.id,
clientId: client.id,
type: 'birthday',
title: `${client.firstName}'s Birthday`,
date: client.birthday,
recurring: true,
reminderDays: 7,
})
.returning();
created.push(event);
}
}
// Create anniversary event if client has anniversary
if (client.anniversary) {
const [existing] = await db.select()
.from(events)
.where(and(
eq(events.clientId, client.id),
eq(events.type, 'anniversary')
))
.limit(1);
if (!existing) {
const [event] = await db.insert(events)
.values({
userId: user.id,
clientId: client.id,
type: 'anniversary',
title: `${client.firstName}'s Anniversary`,
date: client.anniversary,
recurring: true,
reminderDays: 7,
})
.returning();
created.push(event);
}
}
return { created };
}, {
params: t.Object({
clientId: t.String({ format: 'uuid' }),
}),
});