Files
network-app-api/src/routes/emails.ts
Hammer 7634306832
Some checks failed
CI/CD / check (push) Successful in 19s
CI/CD / deploy (push) Failing after 1s
fix: resolve TypeScript errors for CI pipeline
- Fix pg-boss Job type imports (PgBoss.Job -> Job from pg-boss)
- Replace deprecated teamConcurrency with localConcurrency
- Add null checks for possibly undefined values (clients, import rows)
- Fix tone type narrowing in profile.ts
- Fix test type assertions (non-null assertions, explicit Record types)
- Extract auth middleware into shared module
- Fix rate limiter Map generic type
2026-01-30 03:27:58 +00:00

462 lines
13 KiB
TypeScript

import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, communications, userProfiles } from '../db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { generateEmail, generateSubject, generateBirthdayMessage, type AIProvider } from '../services/ai';
import { sendEmail } from '../services/email';
import type { User } from '../lib/auth';
import { randomUUID } from 'crypto';
export const emailRoutes = new Elysia({ prefix: '/emails' })
.use(authMiddleware)
// 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');
}
// Get user profile for signature
const [profile] = await db.select()
.from(userProfiles)
.where(eq(userProfiles.userId, user.id))
.limit(1);
// Build advisor info
const advisorInfo = {
name: user.name,
title: profile?.title || '',
company: profile?.company || '',
phone: profile?.phone || '',
signature: profile?.emailSignature || '',
};
// Generate email content
console.log(`[${new Date().toISOString()}] Generating email for client ${client.firstName}, purpose: ${body.purpose}`);
let content: string;
let subject: string;
try {
content = await generateEmail({
advisorName: advisorInfo.name,
advisorTitle: advisorInfo.title,
advisorCompany: advisorInfo.company,
advisorPhone: advisorInfo.phone,
advisorSignature: advisorInfo.signature,
clientName: client.firstName,
interests: client.interests || [],
notes: client.notes || '',
purpose: body.purpose,
provider: body.provider,
communicationStyle: profile?.communicationStyle as any,
});
console.log(`[${new Date().toISOString()}] Email content generated successfully`);
} catch (e) {
console.error(`[${new Date().toISOString()}] Failed to generate email content:`, e);
throw e;
}
// Generate subject
try {
subject = await generateSubject(body.purpose, client.firstName, body.provider);
console.log(`[${new Date().toISOString()}] Email subject generated successfully`);
} catch (e) {
console.error(`[${new Date().toISOString()}] Failed to generate subject:`, e);
throw e;
}
// 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 }) => {
console.log(`[${new Date().toISOString()}] Send email request for id: ${params.id}`);
// 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) {
console.log(`[${new Date().toISOString()}] Email not found or already sent`);
throw new Error('Email not found or already sent');
}
if (!email.client.email) {
console.log(`[${new Date().toISOString()}] Client has no email address`);
throw new Error('Client has no email address');
}
console.log(`[${new Date().toISOString()}] Sending email to: ${email.client.email}`);
// Send via Resend
try {
await sendEmail({
to: email.client.email,
subject: email.email.subject || 'Message from your advisor',
content: email.email.content,
});
console.log(`[${new Date().toISOString()}] Email sent successfully`);
} catch (e) {
console.error(`[${new Date().toISOString()}] Failed to send email:`, e);
throw e;
}
// 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' }),
}),
})
// Bulk generate emails
.post('/bulk-generate', async ({ body, user }: {
body: { clientIds: string[]; purpose: string; provider?: AIProvider };
user: User;
}) => {
const batchId = randomUUID();
// Get user profile
const [profile] = await db.select()
.from(userProfiles)
.where(eq(userProfiles.userId, user.id))
.limit(1);
const advisorInfo = {
name: user.name,
title: profile?.title || '',
company: profile?.company || '',
phone: profile?.phone || '',
signature: profile?.emailSignature || '',
};
// Get all selected clients
const selectedClients = await db.select()
.from(clients)
.where(and(
inArray(clients.id, body.clientIds),
eq(clients.userId, user.id),
));
if (selectedClients.length === 0) {
throw new Error('No valid clients found');
}
const results = [];
for (const client of selectedClients) {
try {
const content = await generateEmail({
advisorName: advisorInfo.name,
advisorTitle: advisorInfo.title,
advisorCompany: advisorInfo.company,
advisorPhone: advisorInfo.phone,
advisorSignature: advisorInfo.signature,
clientName: client.firstName,
interests: client.interests || [],
notes: client.notes || '',
purpose: body.purpose,
provider: body.provider,
});
const subject = await generateSubject(body.purpose, client.firstName, body.provider);
const [comm] = await db.insert(communications)
.values({
userId: user.id,
clientId: client.id,
type: 'email',
subject,
content,
aiGenerated: true,
aiModel: body.provider || 'anthropic',
status: 'draft',
batchId,
})
.returning();
results.push({ clientId: client.id, email: comm, success: true });
} catch (error: any) {
results.push({ clientId: client.id, error: error.message, success: false });
}
}
return { batchId, results, total: selectedClients.length, generated: results.filter(r => r.success).length };
}, {
body: t.Object({
clientIds: t.Array(t.String({ format: 'uuid' }), { minItems: 1 }),
purpose: t.String({ minLength: 1 }),
provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])),
}),
})
// Bulk send all drafts in a batch
.post('/bulk-send', async ({ body, user }: {
body: { batchId: string };
user: User;
}) => {
// Get all drafts in this batch
const drafts = await db.select({
email: communications,
client: clients,
})
.from(communications)
.innerJoin(clients, eq(communications.clientId, clients.id))
.where(and(
eq(communications.batchId, body.batchId),
eq(communications.userId, user.id),
eq(communications.status, 'draft'),
));
const results = [];
for (const { email, client } of drafts) {
if (!client.email) {
results.push({ id: email.id, success: false, error: 'Client has no email' });
continue;
}
try {
await sendEmail({
to: client.email,
subject: email.subject || 'Message from your advisor',
content: email.content,
});
await db.update(communications)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(communications.id, email.id));
await db.update(clients)
.set({ lastContactedAt: new Date() })
.where(eq(clients.id, client.id));
results.push({ id: email.id, success: true });
} catch (error: any) {
results.push({ id: email.id, success: false, error: error.message });
}
}
return {
batchId: body.batchId,
total: drafts.length,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results,
};
}, {
body: t.Object({
batchId: t.String({ minLength: 1 }),
}),
})
// 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' }),
}),
});