Initial API scaffold: Elysia + Bun + Drizzle + BetterAuth + LangChain
This commit is contained in:
16
src/db/index.ts
Normal file
16
src/db/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL environment variable is required');
|
||||
}
|
||||
|
||||
// For query purposes
|
||||
const queryClient = postgres(connectionString);
|
||||
export const db = drizzle(queryClient, { schema });
|
||||
|
||||
// For migrations (uses a different client)
|
||||
export const migrationClient = postgres(connectionString, { max: 1 });
|
||||
163
src/db/schema.ts
Normal file
163
src/db/schema.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { pgTable, text, timestamp, uuid, boolean, jsonb, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// Users table (managed by BetterAuth, but we define it for relations)
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
email: text('email').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
emailVerified: boolean('email_verified').default(false),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// BetterAuth session table
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// BetterAuth account table (for OAuth providers)
|
||||
export const accounts = pgTable('accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: text('scope'),
|
||||
idToken: text('id_token'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// BetterAuth verification table
|
||||
export const verifications = pgTable('verifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Clients table
|
||||
export const clients = pgTable('clients', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
|
||||
// Basic info
|
||||
firstName: text('first_name').notNull(),
|
||||
lastName: text('last_name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
|
||||
// Address
|
||||
street: text('street'),
|
||||
city: text('city'),
|
||||
state: text('state'),
|
||||
zip: text('zip'),
|
||||
|
||||
// Professional
|
||||
company: text('company'),
|
||||
role: text('role'),
|
||||
industry: text('industry'),
|
||||
|
||||
// Personal
|
||||
birthday: timestamp('birthday'),
|
||||
anniversary: timestamp('anniversary'),
|
||||
interests: jsonb('interests').$type<string[]>().default([]),
|
||||
family: jsonb('family').$type<{ spouse?: string; children?: string[] }>(),
|
||||
notes: text('notes'),
|
||||
|
||||
// Organization
|
||||
tags: jsonb('tags').$type<string[]>().default([]),
|
||||
|
||||
// Tracking
|
||||
lastContactedAt: timestamp('last_contacted_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Events table (birthdays, anniversaries, follow-ups)
|
||||
export const events = pgTable('events', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
|
||||
|
||||
type: text('type').notNull(), // 'birthday' | 'anniversary' | 'followup' | 'custom'
|
||||
title: text('title').notNull(),
|
||||
date: timestamp('date').notNull(),
|
||||
recurring: boolean('recurring').default(false),
|
||||
reminderDays: integer('reminder_days').default(7),
|
||||
lastTriggered: timestamp('last_triggered'),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Communications table (emails, messages)
|
||||
export const communications = pgTable('communications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
|
||||
|
||||
type: text('type').notNull(), // 'email' | 'birthday' | 'followup'
|
||||
subject: text('subject'),
|
||||
content: text('content').notNull(),
|
||||
aiGenerated: boolean('ai_generated').default(false),
|
||||
aiModel: text('ai_model'), // Which model was used
|
||||
status: text('status').default('draft'), // 'draft' | 'approved' | 'sent'
|
||||
sentAt: timestamp('sent_at'),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
clients: many(clients),
|
||||
events: many(events),
|
||||
communications: many(communications),
|
||||
sessions: many(sessions),
|
||||
accounts: many(accounts),
|
||||
}));
|
||||
|
||||
export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
user: one(users, {
|
||||
fields: [clients.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
events: many(events),
|
||||
communications: many(communications),
|
||||
}));
|
||||
|
||||
export const eventsRelations = relations(events, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [events.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
client: one(clients, {
|
||||
fields: [events.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const communicationsRelations = relations(communications, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [communications.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
client: one(clients, {
|
||||
fields: [communications.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
}));
|
||||
71
src/index.ts
Normal file
71
src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import { cors } from '@elysiajs/cors';
|
||||
import { auth } from './lib/auth';
|
||||
import { clientRoutes } from './routes/clients';
|
||||
import { emailRoutes } from './routes/emails';
|
||||
import { eventRoutes } from './routes/events';
|
||||
import type { User } from './lib/auth';
|
||||
|
||||
const app = new Elysia()
|
||||
// CORS
|
||||
.use(cors({
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
|
||||
credentials: true,
|
||||
}))
|
||||
|
||||
// Health check
|
||||
.get('/health', () => ({ status: 'ok', timestamp: new Date().toISOString() }))
|
||||
|
||||
// BetterAuth routes (login, register, etc.)
|
||||
.all('/api/auth/*', async ({ request }) => {
|
||||
return auth.handler(request);
|
||||
})
|
||||
|
||||
// Protected routes - require auth
|
||||
.derive(async ({ request, set }): Promise<{ user: User }> => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
set.status = 401;
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return { user: session.user as User };
|
||||
})
|
||||
|
||||
// API routes (all require auth due to derive above)
|
||||
.group('/api', app => app
|
||||
.use(clientRoutes)
|
||||
.use(emailRoutes)
|
||||
.use(eventRoutes)
|
||||
)
|
||||
|
||||
// Error handler
|
||||
.onError(({ code, error, set }) => {
|
||||
if (code === 'VALIDATION') {
|
||||
set.status = 400;
|
||||
return { error: 'Validation error', details: error.message };
|
||||
}
|
||||
|
||||
if (error.message === 'Unauthorized') {
|
||||
set.status = 401;
|
||||
return { error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
set.status = 404;
|
||||
return { error: error.message };
|
||||
}
|
||||
|
||||
console.error('Error:', error);
|
||||
set.status = 500;
|
||||
return { error: 'Internal server error' };
|
||||
})
|
||||
|
||||
.listen(process.env.PORT || 3000);
|
||||
|
||||
console.log(`🚀 Network App API running at ${app.server?.hostname}:${app.server?.port}`);
|
||||
|
||||
export type App = typeof app;
|
||||
30
src/lib/auth.ts
Normal file
30
src/lib/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { db } from '../db';
|
||||
import * as schema from '../db/schema';
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verifications,
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // Enable later for production
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update session every day
|
||||
},
|
||||
trustedOrigins: [
|
||||
process.env.APP_URL || 'http://localhost:3000',
|
||||
],
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
194
src/routes/clients.ts
Normal file
194
src/routes/clients.ts
Normal 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
269
src/routes/emails.ts
Normal 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
281
src/routes/events.ts
Normal 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' }),
|
||||
}),
|
||||
});
|
||||
109
src/services/ai.ts
Normal file
109
src/services/ai.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
|
||||
export type AIProvider = 'anthropic' | 'openai';
|
||||
|
||||
// Get model based on provider
|
||||
function getModel(provider: AIProvider = 'anthropic') {
|
||||
if (provider === 'anthropic') {
|
||||
return new ChatAnthropic({
|
||||
modelName: 'claude-sonnet-4-20250514',
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
// Add OpenAI support later
|
||||
throw new Error(`Provider ${provider} not yet supported`);
|
||||
}
|
||||
|
||||
// Email generation prompt
|
||||
const emailPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `You are a professional wealth advisor writing to a valued client.
|
||||
Maintain a warm but professional tone. Incorporate personal details naturally.
|
||||
Keep emails concise (3-4 paragraphs max).
|
||||
Do not include subject line - just the body.`],
|
||||
['human', `Advisor: {advisorName}
|
||||
Client: {clientName}
|
||||
Their interests: {interests}
|
||||
Recent notes: {notes}
|
||||
Purpose: {purpose}
|
||||
|
||||
Generate a personalized email that feels genuine, not templated.`],
|
||||
]);
|
||||
|
||||
export interface GenerateEmailParams {
|
||||
advisorName: string;
|
||||
clientName: string;
|
||||
interests: string[];
|
||||
notes: string;
|
||||
purpose: string;
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export async function generateEmail(params: GenerateEmailParams): Promise<string> {
|
||||
const model = getModel(params.provider);
|
||||
const parser = new StringOutputParser();
|
||||
const chain = emailPrompt.pipe(model).pipe(parser);
|
||||
|
||||
const response = await chain.invoke({
|
||||
advisorName: params.advisorName,
|
||||
clientName: params.clientName,
|
||||
interests: params.interests.join(', ') || 'not specified',
|
||||
notes: params.notes || 'No recent notes',
|
||||
purpose: params.purpose,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Birthday message generation
|
||||
const birthdayPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `Generate a thoughtful birthday message from a wealth advisor to their client.
|
||||
Should feel personal, not generic. Keep it brief (2-3 sentences) and sincere.`],
|
||||
['human', `Client: {clientName}
|
||||
Years as client: {yearsAsClient}
|
||||
Interests: {interests}
|
||||
|
||||
Generate a warm birthday message.`],
|
||||
]);
|
||||
|
||||
export interface GenerateBirthdayMessageParams {
|
||||
clientName: string;
|
||||
yearsAsClient: number;
|
||||
interests: string[];
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export async function generateBirthdayMessage(params: GenerateBirthdayMessageParams): Promise<string> {
|
||||
const model = getModel(params.provider);
|
||||
const parser = new StringOutputParser();
|
||||
const chain = birthdayPrompt.pipe(model).pipe(parser);
|
||||
|
||||
const response = await chain.invoke({
|
||||
clientName: params.clientName,
|
||||
yearsAsClient: params.yearsAsClient.toString(),
|
||||
interests: params.interests.join(', ') || 'not specified',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Email subject generation
|
||||
const subjectPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `Generate a professional but warm email subject line for a wealth advisor's email.
|
||||
Keep it short (under 50 characters). Do not use quotes.`],
|
||||
['human', `Purpose: {purpose}
|
||||
Client name: {clientName}
|
||||
|
||||
Generate just the subject line, nothing else.`],
|
||||
]);
|
||||
|
||||
export async function generateSubject(purpose: string, clientName: string, provider?: AIProvider): Promise<string> {
|
||||
const model = getModel(provider);
|
||||
const parser = new StringOutputParser();
|
||||
const chain = subjectPrompt.pipe(model).pipe(parser);
|
||||
|
||||
const response = await chain.invoke({ purpose, clientName });
|
||||
return response.trim();
|
||||
}
|
||||
27
src/services/email.ts
Normal file
27
src/services/email.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(params: SendEmailParams) {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: params.from || process.env.DEFAULT_FROM_EMAIL || 'onboarding@resend.dev',
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
text: params.content,
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
28
src/types/index.ts
Normal file
28
src/types/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
import type { clients, events, communications, users } from '../db/schema';
|
||||
|
||||
// Database types
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type NewUser = InferInsertModel<typeof users>;
|
||||
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type NewClient = InferInsertModel<typeof clients>;
|
||||
|
||||
export type Event = InferSelectModel<typeof events>;
|
||||
export type NewEvent = InferInsertModel<typeof events>;
|
||||
|
||||
export type Communication = InferSelectModel<typeof communications>;
|
||||
export type NewCommunication = InferInsertModel<typeof communications>;
|
||||
|
||||
// API types
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
Reference in New Issue
Block a user