Update network-app docs: new stack (Flutter, Elysia, Bun, Postgres)
This commit is contained in:
476
projects/network-app/architecture.md
Normal file
476
projects/network-app/architecture.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# 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<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
|
||||
|
||||
```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 <david@nwm.com>',
|
||||
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<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
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user