diff --git a/src/db/schema.ts b/src/db/schema.ts index 5360503..209de47 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -197,6 +197,46 @@ export const clientNotes = pgTable('client_notes', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Email templates table +export const emailTemplates = pgTable('email_templates', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + name: text('name').notNull(), + category: text('category').notNull(), // 'follow-up' | 'birthday' | 'introduction' | 'check-in' | 'thank-you' | 'custom' + subject: text('subject').notNull(), + content: text('content').notNull(), // supports {{firstName}}, {{lastName}}, {{company}} placeholders + isDefault: boolean('is_default').default(false), // mark as default for category + usageCount: integer('usage_count').default(0), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Saved client filters / segments +export const clientSegments = pgTable('client_segments', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + name: text('name').notNull(), + description: text('description'), + filters: jsonb('filters').$type<{ + stages?: string[]; + tags?: string[]; + industries?: string[]; + cities?: string[]; + states?: string[]; + lastContactedBefore?: string; // ISO date + lastContactedAfter?: string; + createdBefore?: string; + createdAfter?: string; + hasEmail?: boolean; + hasPhone?: boolean; + search?: string; + }>().notNull(), + color: text('color').default('#3b82f6'), // badge color + pinned: boolean('pinned').default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + // Relations export const usersRelations = relations(users, ({ many }) => ({ clients: many(clients), @@ -206,6 +246,22 @@ export const usersRelations = relations(users, ({ many }) => ({ interactions: many(interactions), sessions: many(sessions), accounts: many(accounts), + emailTemplates: many(emailTemplates), + clientSegments: many(clientSegments), +})); + +export const emailTemplatesRelations = relations(emailTemplates, ({ one }) => ({ + user: one(users, { + fields: [emailTemplates.userId], + references: [users.id], + }), +})); + +export const clientSegmentsRelations = relations(clientSegments, ({ one }) => ({ + user: one(users, { + fields: [clientSegments.userId], + references: [users.id], + }), })); export const clientsRelations = relations(clients, ({ one, many }) => ({ diff --git a/src/index.ts b/src/index.ts index 709922b..09c11bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ import { reportsRoutes } from './routes/reports'; import { notesRoutes } from './routes/notes'; import { notificationRoutes } from './routes/notifications'; import { interactionRoutes } from './routes/interactions'; +import { templateRoutes } from './routes/templates'; +import { segmentRoutes } from './routes/segments'; import { db } from './db'; import { users } from './db/schema'; import { eq } from 'drizzle-orm'; @@ -77,6 +79,8 @@ const app = new Elysia() .use(notesRoutes) .use(notificationRoutes) .use(interactionRoutes) + .use(templateRoutes) + .use(segmentRoutes) ) // Error handler diff --git a/src/routes/segments.ts b/src/routes/segments.ts new file mode 100644 index 0000000..74968e3 --- /dev/null +++ b/src/routes/segments.ts @@ -0,0 +1,246 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clientSegments, clients } from '../db/schema'; +import { eq, and, desc, or, ilike, inArray, gte, lte, isNotNull, isNull, sql } from 'drizzle-orm'; +import type { User } from '../lib/auth'; + +type SegmentFilters = { + stages?: string[]; + tags?: string[]; + industries?: string[]; + cities?: string[]; + states?: string[]; + lastContactedBefore?: string; + lastContactedAfter?: string; + createdBefore?: string; + createdAfter?: string; + hasEmail?: boolean; + hasPhone?: boolean; + search?: string; +}; + +function buildClientConditions(filters: SegmentFilters, userId: string) { + const conditions = [eq(clients.userId, userId)]; + + if (filters.stages?.length) { + conditions.push(inArray(clients.stage, filters.stages)); + } + if (filters.industries?.length) { + conditions.push(inArray(clients.industry, filters.industries)); + } + if (filters.cities?.length) { + conditions.push(inArray(clients.city, filters.cities)); + } + if (filters.states?.length) { + conditions.push(inArray(clients.state, filters.states)); + } + if (filters.tags?.length) { + // Check if client tags JSONB array contains any of the filter tags + const tagConditions = filters.tags.map(tag => + sql`${clients.tags}::jsonb @> ${JSON.stringify([tag])}::jsonb` + ); + conditions.push(or(...tagConditions)!); + } + if (filters.lastContactedBefore) { + conditions.push(lte(clients.lastContactedAt, new Date(filters.lastContactedBefore))); + } + if (filters.lastContactedAfter) { + conditions.push(gte(clients.lastContactedAt, new Date(filters.lastContactedAfter))); + } + if (filters.createdBefore) { + conditions.push(lte(clients.createdAt, new Date(filters.createdBefore))); + } + if (filters.createdAfter) { + conditions.push(gte(clients.createdAt, new Date(filters.createdAfter))); + } + if (filters.hasEmail === true) { + conditions.push(isNotNull(clients.email)); + } else if (filters.hasEmail === false) { + conditions.push(isNull(clients.email)); + } + if (filters.hasPhone === true) { + conditions.push(isNotNull(clients.phone)); + } else if (filters.hasPhone === false) { + conditions.push(isNull(clients.phone)); + } + if (filters.search) { + const q = `%${filters.search}%`; + conditions.push(or( + ilike(clients.firstName, q), + ilike(clients.lastName, q), + ilike(clients.email, q), + ilike(clients.company, q), + )!); + } + + return conditions; +} + +export const segmentRoutes = new Elysia({ prefix: '/segments' }) + // List saved segments + .get('/', async ({ user }: { user: User }) => { + return db.select() + .from(clientSegments) + .where(eq(clientSegments.userId, user.id)) + .orderBy(desc(clientSegments.pinned), desc(clientSegments.updatedAt)); + }) + + // Preview segment (apply filters, return matching clients) + .post('/preview', async ({ body, user }: { body: { filters: SegmentFilters }; user: User }) => { + const conditions = buildClientConditions(body.filters, user.id); + const results = await db.select() + .from(clients) + .where(and(...conditions)) + .orderBy(clients.lastName); + return { count: results.length, clients: results }; + }, { + body: t.Object({ + filters: t.Object({ + stages: t.Optional(t.Array(t.String())), + tags: t.Optional(t.Array(t.String())), + industries: t.Optional(t.Array(t.String())), + cities: t.Optional(t.Array(t.String())), + states: t.Optional(t.Array(t.String())), + lastContactedBefore: t.Optional(t.String()), + lastContactedAfter: t.Optional(t.String()), + createdBefore: t.Optional(t.String()), + createdAfter: t.Optional(t.String()), + hasEmail: t.Optional(t.Boolean()), + hasPhone: t.Optional(t.Boolean()), + search: t.Optional(t.String()), + }), + }), + }) + + // Get filter options (unique values for dropdowns) + .get('/filter-options', async ({ user }: { user: User }) => { + const allClients = await db.select({ + industry: clients.industry, + city: clients.city, + state: clients.state, + tags: clients.tags, + stage: clients.stage, + }) + .from(clients) + .where(eq(clients.userId, user.id)); + + const industries = [...new Set(allClients.map(c => c.industry).filter(Boolean))] as string[]; + const cities = [...new Set(allClients.map(c => c.city).filter(Boolean))] as string[]; + const states = [...new Set(allClients.map(c => c.state).filter(Boolean))] as string[]; + const tags = [...new Set(allClients.flatMap(c => (c.tags as string[]) || []))]; + const stages = [...new Set(allClients.map(c => c.stage).filter(Boolean))] as string[]; + + return { industries: industries.sort(), cities: cities.sort(), states: states.sort(), tags: tags.sort(), stages: stages.sort() }; + }) + + // Create segment + .post('/', async ({ body, user }: { + body: { name: string; description?: string; filters: SegmentFilters; color?: string; pinned?: boolean }; + user: User; + }) => { + const [segment] = await db.insert(clientSegments) + .values({ + userId: user.id, + name: body.name, + description: body.description, + filters: body.filters, + color: body.color || '#3b82f6', + pinned: body.pinned || false, + }) + .returning(); + return segment; + }, { + body: t.Object({ + name: t.String({ minLength: 1 }), + description: t.Optional(t.String()), + filters: t.Object({ + stages: t.Optional(t.Array(t.String())), + tags: t.Optional(t.Array(t.String())), + industries: t.Optional(t.Array(t.String())), + cities: t.Optional(t.Array(t.String())), + states: t.Optional(t.Array(t.String())), + lastContactedBefore: t.Optional(t.String()), + lastContactedAfter: t.Optional(t.String()), + createdBefore: t.Optional(t.String()), + createdAfter: t.Optional(t.String()), + hasEmail: t.Optional(t.Boolean()), + hasPhone: t.Optional(t.Boolean()), + search: t.Optional(t.String()), + }), + color: t.Optional(t.String()), + pinned: t.Optional(t.Boolean()), + }), + }) + + // Get segment + matching clients + .get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [segment] = await db.select() + .from(clientSegments) + .where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id))) + .limit(1); + if (!segment) throw new Error('Segment not found'); + + const conditions = buildClientConditions(segment.filters as SegmentFilters, user.id); + const matchingClients = await db.select() + .from(clients) + .where(and(...conditions)) + .orderBy(clients.lastName); + + return { ...segment, clientCount: matchingClients.length, clients: matchingClients }; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + }) + + // Update segment + .put('/:id', async ({ params, body, user }: { + params: { id: string }; + body: { name?: string; description?: string; filters?: SegmentFilters; color?: string; pinned?: boolean }; + user: User; + }) => { + const updateData: Record = { updatedAt: new Date() }; + if (body.name !== undefined) updateData.name = body.name; + if (body.description !== undefined) updateData.description = body.description; + if (body.filters !== undefined) updateData.filters = body.filters; + if (body.color !== undefined) updateData.color = body.color; + if (body.pinned !== undefined) updateData.pinned = body.pinned; + + const [segment] = await db.update(clientSegments) + .set(updateData) + .where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id))) + .returning(); + if (!segment) throw new Error('Segment not found'); + return segment; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: t.Object({ + name: t.Optional(t.String({ minLength: 1 })), + description: t.Optional(t.String()), + filters: t.Optional(t.Object({ + stages: t.Optional(t.Array(t.String())), + tags: t.Optional(t.Array(t.String())), + industries: t.Optional(t.Array(t.String())), + cities: t.Optional(t.Array(t.String())), + states: t.Optional(t.Array(t.String())), + lastContactedBefore: t.Optional(t.String()), + lastContactedAfter: t.Optional(t.String()), + createdBefore: t.Optional(t.String()), + createdAfter: t.Optional(t.String()), + hasEmail: t.Optional(t.Boolean()), + hasPhone: t.Optional(t.Boolean()), + search: t.Optional(t.String()), + })), + color: t.Optional(t.String()), + pinned: t.Optional(t.Boolean()), + }), + }) + + // Delete segment + .delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [deleted] = await db.delete(clientSegments) + .where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id))) + .returning({ id: clientSegments.id }); + if (!deleted) throw new Error('Segment not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + }); diff --git a/src/routes/templates.ts b/src/routes/templates.ts new file mode 100644 index 0000000..3b7318e --- /dev/null +++ b/src/routes/templates.ts @@ -0,0 +1,145 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { emailTemplates } from '../db/schema'; +import { eq, and, desc, sql } from 'drizzle-orm'; +import type { User } from '../lib/auth'; + +export const templateRoutes = new Elysia({ prefix: '/templates' }) + // List templates + .get('/', async ({ query, user }: { query: { category?: string }; user: User }) => { + let conditions = [eq(emailTemplates.userId, user.id)]; + if (query.category) { + conditions.push(eq(emailTemplates.category, query.category)); + } + return db.select() + .from(emailTemplates) + .where(and(...conditions)) + .orderBy(desc(emailTemplates.usageCount)); + }, { + query: t.Object({ + category: t.Optional(t.String()), + }), + }) + + // Get single template + .get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => { + const [template] = await db.select() + .from(emailTemplates) + .where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id))) + .limit(1); + if (!template) throw new Error('Template not found'); + return template; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + }) + + // Create template + .post('/', async ({ body, user }: { body: { name: string; category: string; subject: string; content: string; isDefault?: boolean }; user: User }) => { + // If marking as default, unmark others in same category + if (body.isDefault) { + await db.update(emailTemplates) + .set({ isDefault: false }) + .where(and(eq(emailTemplates.userId, user.id), eq(emailTemplates.category, body.category))); + } + const [template] = await db.insert(emailTemplates) + .values({ + userId: user.id, + name: body.name, + category: body.category, + subject: body.subject, + content: body.content, + isDefault: body.isDefault || false, + }) + .returning(); + return template; + }, { + body: t.Object({ + name: t.String({ minLength: 1 }), + category: t.String({ minLength: 1 }), + subject: t.String({ minLength: 1 }), + content: t.String({ minLength: 1 }), + isDefault: t.Optional(t.Boolean()), + }), + }) + + // Update template + .put('/:id', async (ctx: any) => { + const { params, body, user } = ctx as { + params: { id: string }; + body: { name?: string; category?: string; subject?: string; content?: string; isDefault?: boolean }; + user: User; + }; + // If marking as default, unmark others + if (body.isDefault && body.category) { + await db.update(emailTemplates) + .set({ isDefault: false }) + .where(and(eq(emailTemplates.userId, user.id), eq(emailTemplates.category, body.category))); + } + const updateData: Record = { updatedAt: new Date() }; + if (body.name !== undefined) updateData.name = body.name; + if (body.category !== undefined) updateData.category = body.category; + if (body.subject !== undefined) updateData.subject = body.subject; + if (body.content !== undefined) updateData.content = body.content; + if (body.isDefault !== undefined) updateData.isDefault = body.isDefault; + + const [template] = await db.update(emailTemplates) + .set(updateData) + .where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id))) + .returning(); + if (!template) throw new Error('Template not found'); + return template; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: t.Object({ + name: t.Optional(t.String({ minLength: 1 })), + category: t.Optional(t.String({ minLength: 1 })), + subject: t.Optional(t.String({ minLength: 1 })), + content: t.Optional(t.String({ minLength: 1 })), + isDefault: t.Optional(t.Boolean()), + }), + }) + + // Use template (increment usage count + return with placeholders filled) + .post('/:id/use', async (ctx: any) => { + const { params, body, user } = ctx; + const [template] = await db.select() + .from(emailTemplates) + .where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id))) + .limit(1); + if (!template) throw new Error('Template not found'); + + // Increment usage count + await db.update(emailTemplates) + .set({ usageCount: sql`${emailTemplates.usageCount} + 1` }) + .where(eq(emailTemplates.id, params.id)); + + // Fill placeholders + let subject = template.subject; + let content = template.content; + const vars = body.variables || {}; + for (const [key, value] of Object.entries(vars)) { + const re = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + subject = subject.replace(re, value as string); + content = content.replace(re, value as string); + } + + return { subject, content, templateId: template.id, templateName: template.name }; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: t.Object({ + clientId: t.Optional(t.String({ format: 'uuid' })), + variables: t.Optional(t.Record(t.String(), t.String())), + }), + }) + + // Delete template + .delete('/:id', async (ctx: any) => { + const { params, user } = ctx; + const [deleted] = await db.delete(emailTemplates) + .where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id))) + .returning({ id: emailTemplates.id }); + if (!deleted) throw new Error('Template not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + });