CRM Technical Design Document (TDD)
Contact Relationship Management System
Document Information
- Version: 1.0
- Date: October 2025
- References: crm-requirements-document.md, crm-software-design-document.md
Technical Stack
Backend Technologies
- Language: Rust 1.75+
- Framework: Actix-web 4.0
- Database: PostgreSQL 15 + Redis 7
- Message Queue: Google Cloud Pub/Sub
- Container: Docker with Alpine Linux
Frontend Technologies
- Framework: React 18 with TypeScript 5
- State Management: Zustand + React Query
- UI Library: Chakra UI v2
- Build Tool: Vite 5
Infrastructure
- Cloud Provider: Google Cloud Platform
- Container Orchestration: Cloud Run / GKE
- CDN: Cloud CDN
- Monitoring: Cloud Monitoring + OpenTelemetry
Implementation Details
Contact Service Implementation
// crm-module/src/services/contact_service.rs
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateContactRequest {
pub email: String,
pub name: String,
pub company: Option<String>,
pub tags: Vec<String>,
}
pub struct ContactService {
db: PgPool,
cache: RedisClient,
enrichment_queue: PubSubPublisher,
}
impl ContactService {
pub async fn create_contact(
&self,
user_id: Uuid,
req: CreateContactRequest,
) -> Result<Contact, ServiceError> {
// Check for duplicates
if let Some(existing) = self.find_duplicate(&user_id, &req.email).await? {
return Ok(existing);
}
// Create contact
let contact = sqlx::query_as!(
Contact,
r#"
INSERT INTO contacts (user_id, email, name, company, tags)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
user_id,
req.email,
req.name,
req.company,
&req.tags
)
.fetch_one(&self.db)
.await?;
// Cache the contact
self.cache.set(&contact.cache_key(), &contact, 3600).await?;
// Queue for enrichment
self.enrichment_queue.publish(EnrichmentEvent {
contact_id: contact.id,
user_id,
priority: EnrichmentPriority::Normal,
}).await?;
Ok(contact)
}
}
Enrichment Service Architecture
// crm-module/src/services/enrichment_service.rs
#[async_trait]
pub trait EnrichmentProvider: Send + Sync {
async fn enrich(&self, email: &str) -> Result<EnrichmentData, ProviderError>;
fn name(&self) -> &'static str;
fn cost_credits(&self) -> i32;
}
pub struct EnrichmentOrchestrator {
providers: Vec<Box<dyn EnrichmentProvider>>,
credit_manager: CreditManager,
cache: RedisClient,
}
impl EnrichmentOrchestrator {
pub async fn process_enrichment(&self, event: EnrichmentEvent) -> Result<()> {
// Check user credits
let balance = self.credit_manager.get_balance(event.user_id).await?;
if balance < MIN_CREDITS {
return Err(ServiceError::InsufficientCredits);
}
// Check cache first
let cache_key = format!("enrichment:{}", hash(&event.contact_id));
if let Some(cached) = self.cache.get::<EnrichmentData>(&cache_key).await? {
return self.update_contact_enrichment(event.contact_id, cached).await;
}
// Try providers in order
let mut enrichment_data = EnrichmentData::default();
for provider in &self.providers {
match provider.enrich(&contact.email).await {
Ok(data) => {
enrichment_data.merge(data);
self.credit_manager.deduct(event.user_id, provider.cost_credits()).await?;
}
Err(e) => log::warn!("Provider {} failed: {}", provider.name(), e),
}
}
// Cache and update
self.cache.set(&cache_key, &enrichment_data, 86400 * 7).await?;
self.update_contact_enrichment(event.contact_id, enrichment_data).await?;
Ok(())
}
}
Social Graph Implementation
// crm-module/src/services/social_graph.rs
pub struct SocialGraphService {
db: PgPool,
cache: RedisClient,
}
impl SocialGraphService {
pub async fn find_shortest_path(
&self,
user_id: Uuid,
from_id: Uuid,
to_id: Uuid,
) -> Result<Option<NetworkPath>, ServiceError> {
// Try cache first
let cache_key = format!("path:{}:{}:{}", user_id, from_id, to_id);
if let Some(path) = self.cache.get(&cache_key).await? {
return Ok(Some(path));
}
// Use recursive CTE for path finding
let path = sqlx::query_as!(
PathNode,
r#"
WITH RECURSIVE paths AS (
SELECT
from_contact_id,
to_contact_id,
ARRAY[from_contact_id, to_contact_id] as path,
1 as depth,
strength as total_strength
FROM relationships
WHERE from_contact_id = $1
AND user_id = $2
UNION ALL
SELECT
r.from_contact_id,
r.to_contact_id,
p.path || r.to_contact_id,
p.depth + 1,
p.total_strength * r.strength
FROM relationships r
INNER JOIN paths p ON p.to_contact_id = r.from_contact_id
WHERE NOT r.to_contact_id = ANY(p.path)
AND p.depth < 4
AND r.user_id = $2
)
SELECT * FROM paths
WHERE to_contact_id = $3
ORDER BY depth, total_strength DESC
LIMIT 1
"#,
from_id,
user_id,
to_id
)
.fetch_optional(&self.db)
.await?;
if let Some(path_data) = path {
let network_path = NetworkPath::from(path_data);
self.cache.set(&cache_key, &network_path, 3600).await?;
Ok(Some(network_path))
} else {
Ok(None)
}
}
}
API Endpoints
// crm-module/src/api/routes.rs
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/crm")
.service(
web::scope("/contacts")
.route("", web::post().to(create_contact))
.route("", web::get().to(list_contacts))
.route("/{id}", web::get().to(get_contact))
.route("/{id}", web::put().to(update_contact))
.route("/search", web::get().to(search_contacts))
.route("/import", web::post().to(bulk_import))
)
.service(
web::scope("/graph")
.route("/path", web::get().to(find_path))
.route("/network/{id}", web::get().to(get_network))
.route("/introduce", web::post().to(request_introduction))
)
.service(
web::scope("/viral")
.route("/stats", web::get().to(get_viral_stats))
.route("/leaderboard", web::get().to(get_leaderboard))
)
);
}
Database Schema
-- Core tables
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
company VARCHAR(255),
enrichment_data JSONB DEFAULT '{}',
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_user_email UNIQUE(user_id, email)
);
CREATE TABLE relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
from_contact_id UUID REFERENCES contacts(id),
to_contact_id UUID REFERENCES contacts(id),
strength DECIMAL(3,2) DEFAULT 0.5,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_contacts_user_email ON contacts(user_id, email);
CREATE INDEX idx_contacts_search ON contacts USING gin(
to_tsvector('english', name || ' ' || email || ' ' || COALESCE(company, ''))
);
CREATE INDEX idx_relationships_graph ON relationships(from_contact_id, to_contact_id);
Frontend Implementation
// frontend/src/hooks/useContacts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
export function useContacts() {
const queryClient = useQueryClient();
const contacts = useQuery({
queryKey: ['contacts'],
queryFn: () => api.get('/api/crm/contacts'),
});
const createContact = useMutation({
mutationFn: (data: CreateContactRequest) =>
api.post('/api/crm/contacts', data),
onSuccess: () => {
queryClient.invalidateQueries(['contacts']);
},
});
return { contacts, createContact };
}
// frontend/src/components/ContactGraph.tsx
import { ForceGraph2D } from 'react-force-graph';
export function ContactGraph({ contacts, relationships }) {
const graphData = {
nodes: contacts.map(c => ({ id: c.id, name: c.name })),
links: relationships.map(r => ({
source: r.from_contact_id,
target: r.to_contact_id,
value: r.strength
}))
};
return (
<ForceGraph2D
graphData={graphData}
nodeLabel="name"
linkWidth={link => link.value * 5}
nodeCanvasObject={(node, ctx, globalScale) => {
const label = node.name;
const fontSize = 12/globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
ctx.fillText(label, node.x, node.y);
}}
/>
);
}
Testing Strategy
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn test_create_contact() {
let app = test::init_service(App::new().configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/api/crm/contacts")
.set_json(&CreateContactRequest {
email: "test@example.com",
name: "Test User",
company: Some("Test Corp".to_string()),
tags: vec!["test".to_string()],
})
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
}
#[tokio::test]
async fn test_enrichment_caching() {
let service = EnrichmentOrchestrator::new_test();
// Test implementation
}
}
Performance Optimizations
- Database: Connection pooling, read replicas, partitioning by user_id
- Caching: Redis with appropriate TTLs, cache warming for popular data
- API: Response compression, pagination, field filtering
- Frontend: Virtual scrolling, lazy loading, optimistic updates
Monitoring & Observability
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::WithExportConfig;
pub fn init_telemetry() {
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(
opentelemetry_otlp::new_exporter()
.tonic()
.with_endpoint("http://otel-collector:4317")
)
.install_batch(opentelemetry::runtime::Tokio)
.expect("Failed to install tracer");
global::set_tracer_provider(tracer);
}
Deployment Configuration
# cloud-run-service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: crm-service
spec:
template:
metadata:
annotations:
run.googleapis.com/cpu-allocation: "1"
run.googleapis.com/memory-allocation: "512Mi"
run.googleapis.com/max-instances: "100"
spec:
containers:
- image: gcr.io/project/crm-service:latest
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: crm-secrets
key: database-url
resources:
limits:
cpu: "2"
memory: "1Gi"