feat: pg-boss job queue, notifications, client interactions, bulk email
Some checks failed
CI/CD / check (push) Failing after 18s
CI/CD / deploy (push) Has been skipped

This commit is contained in:
2026-01-30 00:48:07 +00:00
parent bb87ba169a
commit 93fce809e2
7 changed files with 632 additions and 3 deletions

View File

@@ -1,12 +1,12 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events, communications } from '../db/schema';
import { clients, events, communications, interactions } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export interface ActivityItem {
id: string;
type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated';
type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated' | 'interaction';
title: string;
description?: string;
date: string;
@@ -110,6 +110,37 @@ export const activityRoutes = new Elysia({ prefix: '/clients' })
});
}
// Interactions
const clientInteractions = await db.select()
.from(interactions)
.where(and(
eq(interactions.clientId, params.id),
eq(interactions.userId, user.id),
))
.orderBy(desc(interactions.contactedAt));
for (const interaction of clientInteractions) {
const typeLabels: Record<string, string> = {
call: '📞 Phone Call',
meeting: '🤝 Meeting',
email: '✉️ Email',
note: '📝 Note',
other: '📌 Interaction',
};
activities.push({
id: `interaction-${interaction.id}`,
type: 'interaction',
title: `${typeLabels[interaction.type] || typeLabels.other}: ${interaction.title}`,
description: interaction.description || undefined,
date: interaction.contactedAt.toISOString(),
metadata: {
interactionId: interaction.id,
interactionType: interaction.type,
duration: interaction.duration,
},
});
}
// Sort by date descending
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

View File

@@ -1,10 +1,11 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, communications, userProfiles } from '../db/schema';
import { eq, and } from 'drizzle-orm';
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' })
// Generate email for a client
@@ -294,6 +295,147 @@ export const emailRoutes = new Elysia({ prefix: '/emails' })
}),
})
// 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)

141
src/routes/interactions.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { interactions, clients } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const interactionRoutes = new Elysia()
// List interactions for a client
.get('/clients/:clientId/interactions', async ({ params, user }: { params: { clientId: string }; user: User }) => {
// Verify client belongs to user
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 items = await db.select()
.from(interactions)
.where(and(eq(interactions.clientId, params.clientId), eq(interactions.userId, user.id)))
.orderBy(desc(interactions.contactedAt));
return items;
}, {
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
})
// Create interaction
.post('/clients/:clientId/interactions', async ({ params, body, user }: {
params: { clientId: string };
body: { type: string; title: string; description?: string; duration?: number; contactedAt: string };
user: User;
}) => {
// Verify client belongs to user
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 [interaction] = await db.insert(interactions)
.values({
userId: user.id,
clientId: params.clientId,
type: body.type,
title: body.title,
description: body.description,
duration: body.duration,
contactedAt: new Date(body.contactedAt),
})
.returning();
// Auto-update lastContactedAt on the client
const contactDate = new Date(body.contactedAt);
if (!client.lastContactedAt || contactDate > client.lastContactedAt) {
await db.update(clients)
.set({ lastContactedAt: contactDate, updatedAt: new Date() })
.where(eq(clients.id, params.clientId));
}
return interaction;
}, {
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
body: t.Object({
type: t.String({ minLength: 1 }),
title: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
duration: t.Optional(t.Number({ minimum: 0 })),
contactedAt: t.String(),
}),
})
// Update interaction
.put('/interactions/:id', async ({ params, body, user }: {
params: { id: string };
body: { type?: string; title?: string; description?: string; duration?: number; contactedAt?: string };
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.description !== undefined) updateData.description = body.description;
if (body.duration !== undefined) updateData.duration = body.duration;
if (body.contactedAt !== undefined) updateData.contactedAt = new Date(body.contactedAt);
const [updated] = await db.update(interactions)
.set(updateData)
.where(and(eq(interactions.id, params.id), eq(interactions.userId, user.id)))
.returning();
if (!updated) throw new Error('Interaction not found');
return updated;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: t.Object({
type: t.Optional(t.String()),
title: t.Optional(t.String()),
description: t.Optional(t.String()),
duration: t.Optional(t.Number({ minimum: 0 })),
contactedAt: t.Optional(t.String()),
}),
})
// Delete interaction
.delete('/interactions/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(interactions)
.where(and(eq(interactions.id, params.id), eq(interactions.userId, user.id)))
.returning({ id: interactions.id });
if (!deleted) throw new Error('Interaction not found');
return { success: true, id: deleted.id };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Get all recent interactions across all clients (for dashboard)
.get('/interactions/recent', async ({ query, user }: { query: { limit?: string }; user: User }) => {
const limit = query.limit ? parseInt(query.limit) : 10;
const items = await db.select({
interaction: interactions,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(interactions)
.innerJoin(clients, eq(interactions.clientId, clients.id))
.where(eq(interactions.userId, user.id))
.orderBy(desc(interactions.contactedAt))
.limit(limit);
return items.map(({ interaction, client }) => ({
...interaction,
client,
}));
}, {
query: t.Object({ limit: t.Optional(t.String()) }),
});

View File

@@ -0,0 +1,85 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { notifications, clients } from '../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const notificationRoutes = new Elysia({ prefix: '/notifications' })
// List notifications
.get('/', async ({ query, user }: { query: { limit?: string; unreadOnly?: string }; user: User }) => {
const limit = query.limit ? parseInt(query.limit) : 50;
const unreadOnly = query.unreadOnly === 'true';
let conditions = [eq(notifications.userId, user.id)];
if (unreadOnly) {
conditions.push(eq(notifications.read, false));
}
const items = await db.select({
notification: notifications,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(notifications)
.leftJoin(clients, eq(notifications.clientId, clients.id))
.where(and(...conditions))
.orderBy(desc(notifications.createdAt))
.limit(limit);
// Unread count
const [unreadResult] = await db.select({
count: sql<number>`count(*)::int`,
})
.from(notifications)
.where(and(eq(notifications.userId, user.id), eq(notifications.read, false)));
return {
notifications: items.map(({ notification, client }) => ({
...notification,
client: client?.id ? client : null,
})),
unreadCount: unreadResult?.count || 0,
};
}, {
query: t.Object({
limit: t.Optional(t.String()),
unreadOnly: t.Optional(t.String()),
}),
})
// Mark notification as read
.put('/:id/read', async ({ params, user }: { params: { id: string }; user: User }) => {
const [updated] = await db.update(notifications)
.set({ read: true })
.where(and(eq(notifications.id, params.id), eq(notifications.userId, user.id)))
.returning();
if (!updated) throw new Error('Notification not found');
return updated;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Mark all as read
.post('/mark-all-read', async ({ user }: { user: User }) => {
await db.update(notifications)
.set({ read: true })
.where(and(eq(notifications.userId, user.id), eq(notifications.read, false)));
return { success: true };
})
// Delete notification
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(notifications)
.where(and(eq(notifications.id, params.id), eq(notifications.userId, user.id)))
.returning({ id: notifications.id });
if (!deleted) throw new Error('Notification not found');
return { success: true };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
});