Files
notes/projects/network-app/architecture.md

13 KiB

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

// 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)

// 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<string[]>().default([]),
  family: jsonb('family').$type<{
    spouse?: string;
    children?: string[];
  }>(),
  notes: text('notes'),
  
  // Metadata
  tags: jsonb('tags').$type<string[]>().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

// 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)

// 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)

// 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 <david@nwm.com>',
    to: params.to,
    subject: params.subject,
    text: params.content,
  });
  
  if (error) throw error;
  return data;
}

Background Jobs (pg-boss)

// 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)

// providers/clients_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'clients_provider.g.dart';

@riverpod
class ClientsNotifier extends _$ClientsNotifier {
  @override
  Future<List<Client>> build() async {
    return ref.read(clientRepositoryProvider).getClients();
  }
  
  Future<void> addClient(CreateClientDto dto) async {
    final client = await ref.read(clientRepositoryProvider).createClient(dto);
    state = AsyncData([...state.value ?? [], client]);
  }
  
  Future<void> 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<void> 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

# 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

# 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