diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts index 092ebcb..64439f1 100644 --- a/src/__tests__/email.test.ts +++ b/src/__tests__/email.test.ts @@ -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', () => { diff --git a/src/db/schema.ts b/src/db/schema.ts index efed535..f870e1a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -118,6 +118,7 @@ export const clients = pgTable('clients', { // Organization tags: jsonb('tags').$type().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 }) => ({ diff --git a/src/index.ts b/src/index.ts index f25f070..966a9f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/routes/clients.ts b/src/routes/clients.ts index f39daa3..f7287fc 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -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) diff --git a/src/routes/notes.ts b/src/routes/notes.ts new file mode 100644 index 0000000..2e63d58 --- /dev/null +++ b/src/routes/notes.ts @@ -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 = { 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' }), + }), + });