feat: email templates + client segments with advanced filtering
- Email templates: CRUD API with categories, placeholders ({{firstName}}, etc.), usage tracking, default per category
- Client segments: save filtered views with multi-criteria filters (stage, tags, industry, city, state, contact dates, email/phone presence)
- Segment preview: test filters before saving, returns matching client list
- Filter options: GET /api/segments/filter-options returns unique values for all filterable fields
- New tables: email_templates, client_segments (auto-created via db:push)
This commit is contained in:
@@ -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 }) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
246
src/routes/segments.ts
Normal file
246
src/routes/segments.ts
Normal file
@@ -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<string, unknown> = { 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' }) }),
|
||||
});
|
||||
145
src/routes/templates.ts
Normal file
145
src/routes/templates.ts
Normal file
@@ -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<string, unknown> = { 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' }) }),
|
||||
});
|
||||
Reference in New Issue
Block a user