# The Network App — Architecture **Last Updated:** 2026-01-27 ## System Overview ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Flutter App │────▶│ Elysia API │────▶│ PostgreSQL │ │ (iOS/Android) │ │ (Bun runtime) │ │ │ └─────────────────┘ └────────┬────────┘ └─────────────────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ LangChain│ │ Resend │ │ pg-boss │ │ (AI) │ │ (Email) │ │ (Jobs) │ └──────────┘ └──────────┘ └──────────┘ ``` ## Backend Architecture ### Framework: Elysia + Bun ```typescript // Example structure src/ ├── index.ts // Entry point ├── routes/ │ ├── auth.ts // BetterAuth routes │ ├── clients.ts // Client CRUD │ ├── emails.ts // AI email generation │ └── events.ts // Birthday/event tracking ├── services/ │ ├── ai.ts // LangChain integration │ ├── email.ts // Resend integration │ └── jobs.ts // pg-boss job definitions ├── db/ │ ├── schema.ts // Drizzle schema │ ├── migrations/ // SQL migrations │ └── index.ts // DB connection ├── lib/ │ ├── auth.ts // BetterAuth config │ └── validation.ts // Zod schemas └── types/ └── index.ts // Shared types ``` ### Database Schema (Drizzle) ```typescript // db/schema.ts import { pgTable, text, timestamp, uuid, boolean, jsonb } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), name: text('name').notNull(), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), }); export const clients = pgTable('clients', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id).notNull(), // Basic info firstName: text('first_name').notNull(), lastName: text('last_name').notNull(), email: text('email'), phone: text('phone'), // Professional company: text('company'), role: text('role'), industry: text('industry'), // Personal birthday: timestamp('birthday'), anniversary: timestamp('anniversary'), interests: jsonb('interests').$type().default([]), family: jsonb('family').$type<{ spouse?: string; children?: string[]; }>(), notes: text('notes'), // Metadata tags: jsonb('tags').$type().default([]), lastContactedAt: timestamp('last_contacted_at'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), }); export const events = pgTable('events', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id).notNull(), clientId: uuid('client_id').references(() => clients.id).notNull(), type: text('type').notNull(), // 'birthday' | 'anniversary' | 'followup' | 'custom' title: text('title').notNull(), date: timestamp('date').notNull(), recurring: boolean('recurring').default(false), reminderDays: integer('reminder_days').default(7), createdAt: timestamp('created_at').defaultNow(), }); export const communications = pgTable('communications', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id).notNull(), clientId: uuid('client_id').references(() => clients.id).notNull(), type: text('type').notNull(), // 'email' | 'birthday' | 'followup' subject: text('subject'), content: text('content').notNull(), aiGenerated: boolean('ai_generated').default(false), status: text('status').default('draft'), // 'draft' | 'sent' sentAt: timestamp('sent_at'), createdAt: timestamp('created_at').defaultNow(), }); ``` ### API Routes ```typescript // routes/clients.ts import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients } from '../db/schema'; import { eq, and, ilike, or } from 'drizzle-orm'; export const clientRoutes = new Elysia({ prefix: '/clients' }) .get('/', async ({ query, user }) => { const { search, tag } = query; let conditions = [eq(clients.userId, user.id)]; if (search) { conditions.push( or( ilike(clients.firstName, `%${search}%`), ilike(clients.lastName, `%${search}%`), ilike(clients.company, `%${search}%`) ) ); } return db.select().from(clients).where(and(...conditions)); }) .get('/:id', async ({ params, user }) => { const client = await db.select() .from(clients) .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) .limit(1); if (!client[0]) throw new Error('Client not found'); return client[0]; }) .post('/', async ({ body, user }) => { const [client] = await db.insert(clients) .values({ ...body, userId: user.id }) .returning(); return client; }) .put('/:id', async ({ params, body, user }) => { const [client] = await db.update(clients) .set({ ...body, updatedAt: new Date() }) .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))) .returning(); return client; }) .delete('/:id', async ({ params, user }) => { await db.delete(clients) .where(and(eq(clients.id, params.id), eq(clients.userId, user.id))); return { success: true }; }); ``` ### AI Integration (LangChain) ```typescript // services/ai.ts import { ChatOpenAI } from '@langchain/openai'; import { ChatAnthropic } from '@langchain/anthropic'; import { PromptTemplate } from '@langchain/core/prompts'; // Model-agnostic setup const getModel = (provider: 'openai' | 'anthropic' = 'anthropic') => { if (provider === 'anthropic') { return new ChatAnthropic({ modelName: 'claude-3-5-sonnet-20241022', anthropicApiKey: process.env.ANTHROPIC_API_KEY, }); } return new ChatOpenAI({ modelName: 'gpt-4-turbo', openAIApiKey: process.env.OPENAI_API_KEY, }); }; const emailPrompt = PromptTemplate.fromTemplate(` You are a professional wealth advisor writing to a valued client. Maintain a warm but professional tone. Incorporate personal details naturally. Advisor: {advisorName} Client: {clientName} Their interests: {interests} Recent notes: {notes} Purpose: {purpose} Generate a personalized email that feels genuine, not templated. Keep it concise (3-4 paragraphs max). `); export async function generateEmail(params: { advisorName: string; clientName: string; interests: string[]; notes: string; purpose: string; provider?: 'openai' | 'anthropic'; }) { const model = getModel(params.provider); const chain = emailPrompt.pipe(model); const response = await chain.invoke({ advisorName: params.advisorName, clientName: params.clientName, interests: params.interests.join(', '), notes: params.notes, purpose: params.purpose, }); return response.content; } ``` ### Email Service (Resend) ```typescript // services/email.ts import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY); export async function sendEmail(params: { to: string; subject: string; content: string; from?: string; }) { const { data, error } = await resend.emails.send({ from: params.from || 'David ', to: params.to, subject: params.subject, text: params.content, }); if (error) throw error; return data; } ``` ### Background Jobs (pg-boss) ```typescript // services/jobs.ts import PgBoss from 'pg-boss'; import { db } from '../db'; import { events, clients } from '../db/schema'; import { generateEmail } from './ai'; import { sendEmail } from './email'; const boss = new PgBoss(process.env.DATABASE_URL); export async function initJobs() { await boss.start(); // Check for upcoming birthdays daily await boss.schedule('check-birthdays', '0 8 * * *'); // 8am daily boss.work('check-birthdays', async () => { const upcomingBirthdays = await db.query.events.findMany({ where: (events, { eq, and, between }) => and( eq(events.type, 'birthday'), // Check next 7 days between(events.date, new Date(), addDays(new Date(), 7)) ), with: { client: true }, }); for (const event of upcomingBirthdays) { await boss.send('send-birthday-email', { clientId: event.clientId, eventId: event.id, }); } }); boss.work('send-birthday-email', async ({ data }) => { // Generate and queue birthday email const client = await db.query.clients.findFirst({ where: eq(clients.id, data.clientId), }); if (!client) return; const content = await generateEmail({ advisorName: 'David', clientName: client.firstName, interests: client.interests || [], notes: client.notes || '', purpose: 'birthday wishes', }); // Save as draft for review (don't auto-send) await db.insert(communications).values({ userId: client.userId, clientId: client.id, type: 'birthday', subject: `Happy Birthday, ${client.firstName}!`, content, aiGenerated: true, status: 'draft', }); }); } ``` ## Frontend Architecture (Flutter) ### Project Structure ``` lib/ ├── main.dart ├── app/ │ ├── app.dart // MaterialApp setup │ └── router.dart // GoRouter config ├── features/ │ ├── auth/ │ │ ├── data/ // Repository, API │ │ ├── domain/ // Models │ │ └── presentation/ // Screens, widgets │ ├── clients/ │ │ ├── data/ │ │ ├── domain/ │ │ └── presentation/ │ ├── emails/ │ │ ├── data/ │ │ ├── domain/ │ │ └── presentation/ │ └── events/ │ ├── data/ │ ├── domain/ │ └── presentation/ ├── shared/ │ ├── providers/ // Riverpod providers │ ├── services/ // HTTP client, storage │ └── widgets/ // Shared components └── config/ └── env.dart // Environment config ``` ### State Management (Riverpod) ```dart // providers/clients_provider.dart import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'clients_provider.g.dart'; @riverpod class ClientsNotifier extends _$ClientsNotifier { @override Future> build() async { return ref.read(clientRepositoryProvider).getClients(); } Future addClient(CreateClientDto dto) async { final client = await ref.read(clientRepositoryProvider).createClient(dto); state = AsyncData([...state.value ?? [], client]); } Future updateClient(String id, UpdateClientDto dto) async { final updated = await ref.read(clientRepositoryProvider).updateClient(id, dto); state = AsyncData([ for (final c in state.value ?? []) if (c.id == id) updated else c ]); } Future deleteClient(String id) async { await ref.read(clientRepositoryProvider).deleteClient(id); state = AsyncData([ for (final c in state.value ?? []) if (c.id != id) c ]); } } ``` ## Deployment (Dokploy) ### Docker Compose ```yaml # docker-compose.yml version: '3.8' services: api: build: ./api ports: - "3000:3000" environment: - DATABASE_URL=postgresql://postgres:password@db:5432/networkapp - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - RESEND_API_KEY=${RESEND_API_KEY} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} depends_on: - db restart: unless-stopped db: image: postgres:16-alpine volumes: - postgres_data:/var/lib/postgresql/data environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=password - POSTGRES_DB=networkapp restart: unless-stopped volumes: postgres_data: ``` ### API Dockerfile ```dockerfile # api/Dockerfile FROM oven/bun:1 AS base WORKDIR /app FROM base AS install COPY package.json bun.lockb ./ RUN bun install --frozen-lockfile FROM base AS release COPY --from=install /app/node_modules ./node_modules COPY . . ENV NODE_ENV=production EXPOSE 3000 CMD ["bun", "run", "src/index.ts"] ``` ## Security Considerations - **Auth:** BetterAuth handles sessions, CSRF, secure cookies - **API:** All routes require authentication (middleware) - **Data:** User can only access their own clients - **Secrets:** All API keys in environment variables - **HTTPS:** Handled by Dokploy/reverse proxy ## Future Considerations (Post-MVP) - Redis for caching/sessions - Push notifications (FCM/APNs) - File storage (MinIO) - Offline support (SQLite + sync) - Multi-advisor teams - Audit logging