feat: client pipeline stages + notes system
- Added 'stage' column to clients (lead/prospect/onboarding/active/inactive) - New client_notes table with CRUD API at /clients/:id/notes - Notes support pinning, editing, and deletion - Stage field in create/update client endpoints - Fixed flaky email test (env var interference)
This commit is contained in:
@@ -93,11 +93,14 @@ describe('Email Service', () => {
|
||||
|
||||
describe('Default From Email', () => {
|
||||
test('falls back to default when from not provided', () => {
|
||||
const savedEnv = process.env.DEFAULT_FROM_EMAIL;
|
||||
delete process.env.DEFAULT_FROM_EMAIL;
|
||||
const from = undefined;
|
||||
const defaultFrom = 'onboarding@resend.dev';
|
||||
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
||||
|
||||
expect(result).toBe(defaultFrom);
|
||||
if (savedEnv) process.env.DEFAULT_FROM_EMAIL = savedEnv;
|
||||
});
|
||||
|
||||
test('uses provided from when available', () => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const clients = pgTable('clients', {
|
||||
|
||||
// Organization
|
||||
tags: jsonb('tags').$type<string[]>().default([]),
|
||||
stage: text('stage').default('lead'), // 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive'
|
||||
|
||||
// Tracking
|
||||
lastContactedAt: timestamp('last_contacted_at'),
|
||||
@@ -158,6 +159,17 @@ export const communications = pgTable('communications', {
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Client notes table
|
||||
export const clientNotes = pgTable('client_notes', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
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),
|
||||
@@ -174,6 +186,18 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
}),
|
||||
events: many(events),
|
||||
communications: many(communications),
|
||||
notes: many(clientNotes),
|
||||
}));
|
||||
|
||||
export const clientNotesRelations = relations(clientNotes, ({ one }) => ({
|
||||
client: one(clients, {
|
||||
fields: [clientNotes.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [clientNotes.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const eventsRelations = relations(events, ({ one }) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { importRoutes } from './routes/import';
|
||||
import { activityRoutes } from './routes/activity';
|
||||
import { insightsRoutes } from './routes/insights';
|
||||
import { reportsRoutes } from './routes/reports';
|
||||
import { notesRoutes } from './routes/notes';
|
||||
import { db } from './db';
|
||||
import { users } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -70,6 +71,7 @@ const app = new Elysia()
|
||||
.use(networkRoutes)
|
||||
.use(insightsRoutes)
|
||||
.use(reportsRoutes)
|
||||
.use(notesRoutes)
|
||||
)
|
||||
|
||||
// Error handler
|
||||
|
||||
@@ -78,6 +78,7 @@ const clientSchema = t.Object({
|
||||
})),
|
||||
notes: t.Optional(t.String()),
|
||||
tags: t.Optional(t.Array(t.String())),
|
||||
stage: t.Optional(t.String()),
|
||||
});
|
||||
|
||||
const updateClientSchema = t.Partial(clientSchema);
|
||||
@@ -157,6 +158,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
|
||||
family: body.family,
|
||||
notes: body.notes,
|
||||
tags: body.tags || [],
|
||||
stage: body.stage || 'lead',
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -192,6 +194,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
|
||||
if (body.family !== undefined) updateData.family = body.family;
|
||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||
if (body.stage !== undefined) updateData.stage = body.stage;
|
||||
|
||||
const [client] = await db.update(clients)
|
||||
.set(updateData)
|
||||
|
||||
103
src/routes/notes.ts
Normal file
103
src/routes/notes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { clientNotes, clients } from '../db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
export const notesRoutes = new Elysia({ prefix: '/clients/:clientId/notes' })
|
||||
// List notes for a client
|
||||
.get('/', async ({ params, user }: { params: { clientId: string }; user: User }) => {
|
||||
// Verify client belongs to user
|
||||
const [client] = await db.select({ id: clients.id })
|
||||
.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 notes = await db.select()
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, params.clientId))
|
||||
.orderBy(desc(clientNotes.pinned), desc(clientNotes.createdAt));
|
||||
|
||||
return notes;
|
||||
}, {
|
||||
params: t.Object({
|
||||
clientId: t.String({ format: 'uuid' }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Create note
|
||||
.post('/', async ({ params, body, user }: { params: { clientId: string }; body: { content: string }; user: User }) => {
|
||||
// Verify client belongs to user
|
||||
const [client] = await db.select({ id: clients.id })
|
||||
.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 [note] = await db.insert(clientNotes)
|
||||
.values({
|
||||
clientId: params.clientId,
|
||||
userId: user.id,
|
||||
content: body.content,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return note;
|
||||
}, {
|
||||
params: t.Object({
|
||||
clientId: t.String({ format: 'uuid' }),
|
||||
}),
|
||||
body: t.Object({
|
||||
content: t.String({ minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Update note
|
||||
.put('/:noteId', async ({ params, body, user }: { params: { clientId: string; noteId: string }; body: { content?: string; pinned?: boolean }; user: User }) => {
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.content !== undefined) updateData.content = body.content;
|
||||
if (body.pinned !== undefined) updateData.pinned = body.pinned;
|
||||
|
||||
const [note] = await db.update(clientNotes)
|
||||
.set(updateData)
|
||||
.where(and(
|
||||
eq(clientNotes.id, params.noteId),
|
||||
eq(clientNotes.clientId, params.clientId),
|
||||
eq(clientNotes.userId, user.id),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!note) throw new Error('Note not found');
|
||||
return note;
|
||||
}, {
|
||||
params: t.Object({
|
||||
clientId: t.String({ format: 'uuid' }),
|
||||
noteId: t.String({ format: 'uuid' }),
|
||||
}),
|
||||
body: t.Object({
|
||||
content: t.Optional(t.String({ minLength: 1 })),
|
||||
pinned: t.Optional(t.Boolean()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Delete note
|
||||
.delete('/:noteId', async ({ params, user }: { params: { clientId: string; noteId: string }; user: User }) => {
|
||||
const [deleted] = await db.delete(clientNotes)
|
||||
.where(and(
|
||||
eq(clientNotes.id, params.noteId),
|
||||
eq(clientNotes.clientId, params.clientId),
|
||||
eq(clientNotes.userId, user.id),
|
||||
))
|
||||
.returning({ id: clientNotes.id });
|
||||
|
||||
if (!deleted) throw new Error('Note not found');
|
||||
return { success: true, id: deleted.id };
|
||||
}, {
|
||||
params: t.Object({
|
||||
clientId: t.String({ format: 'uuid' }),
|
||||
noteId: t.String({ format: 'uuid' }),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user