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', () => {
|
describe('Default From Email', () => {
|
||||||
test('falls back to default when from not provided', () => {
|
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 from = undefined;
|
||||||
const defaultFrom = 'onboarding@resend.dev';
|
const defaultFrom = 'onboarding@resend.dev';
|
||||||
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
||||||
|
|
||||||
expect(result).toBe(defaultFrom);
|
expect(result).toBe(defaultFrom);
|
||||||
|
if (savedEnv) process.env.DEFAULT_FROM_EMAIL = savedEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses provided from when available', () => {
|
test('uses provided from when available', () => {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const clients = pgTable('clients', {
|
|||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
tags: jsonb('tags').$type<string[]>().default([]),
|
tags: jsonb('tags').$type<string[]>().default([]),
|
||||||
|
stage: text('stage').default('lead'), // 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive'
|
||||||
|
|
||||||
// Tracking
|
// Tracking
|
||||||
lastContactedAt: timestamp('last_contacted_at'),
|
lastContactedAt: timestamp('last_contacted_at'),
|
||||||
@@ -158,6 +159,17 @@ export const communications = pgTable('communications', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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
|
// Relations
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
clients: many(clients),
|
clients: many(clients),
|
||||||
@@ -174,6 +186,18 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
events: many(events),
|
events: many(events),
|
||||||
communications: many(communications),
|
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 }) => ({
|
export const eventsRelations = relations(events, ({ one }) => ({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { importRoutes } from './routes/import';
|
|||||||
import { activityRoutes } from './routes/activity';
|
import { activityRoutes } from './routes/activity';
|
||||||
import { insightsRoutes } from './routes/insights';
|
import { insightsRoutes } from './routes/insights';
|
||||||
import { reportsRoutes } from './routes/reports';
|
import { reportsRoutes } from './routes/reports';
|
||||||
|
import { notesRoutes } from './routes/notes';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -70,6 +71,7 @@ const app = new Elysia()
|
|||||||
.use(networkRoutes)
|
.use(networkRoutes)
|
||||||
.use(insightsRoutes)
|
.use(insightsRoutes)
|
||||||
.use(reportsRoutes)
|
.use(reportsRoutes)
|
||||||
|
.use(notesRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const clientSchema = t.Object({
|
|||||||
})),
|
})),
|
||||||
notes: t.Optional(t.String()),
|
notes: t.Optional(t.String()),
|
||||||
tags: t.Optional(t.Array(t.String())),
|
tags: t.Optional(t.Array(t.String())),
|
||||||
|
stage: t.Optional(t.String()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateClientSchema = t.Partial(clientSchema);
|
const updateClientSchema = t.Partial(clientSchema);
|
||||||
@@ -157,6 +158,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
|
|||||||
family: body.family,
|
family: body.family,
|
||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
tags: body.tags || [],
|
tags: body.tags || [],
|
||||||
|
stage: body.stage || 'lead',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -192,6 +194,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
|
|||||||
if (body.family !== undefined) updateData.family = body.family;
|
if (body.family !== undefined) updateData.family = body.family;
|
||||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||||
|
if (body.stage !== undefined) updateData.stage = body.stage;
|
||||||
|
|
||||||
const [client] = await db.update(clients)
|
const [client] = await db.update(clients)
|
||||||
.set(updateData)
|
.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