feat: pg-boss job queue, notifications, client interactions, bulk email
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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
141
src/routes/interactions.ts
Normal 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()) }),
|
||||
});
|
||||
85
src/routes/notifications.ts
Normal file
85
src/routes/notifications.ts
Normal 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' }) }),
|
||||
});
|
||||
Reference in New Issue
Block a user