feat: client pipeline stages + notes system
Some checks failed
CI/CD / check (push) Failing after 19s
CI/CD / deploy (push) Has been skipped

- 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:
2026-01-30 00:35:49 +00:00
parent 33a0e1d110
commit bb87ba169a
5 changed files with 135 additions and 0 deletions

View File

@@ -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', () => {

View File

@@ -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 }) => ({

View File

@@ -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

View File

@@ -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
View 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' }),
}),
});