Overview
Designed and shipped a modern CRM platform that fuses two pain-points into one workspace: AI-assisted proposal authoring and a multi-platform social media manager. Sales reps generate contextual client proposals in seconds, marketing teams publish across every major network from one screen — and everything stays linked to the underlying customer record.
Problem Statement
Mid-market sales teams juggled three to five disconnected tools to do one job: a CRM for contacts, a doc editor for proposals, and per-platform schedulers (Buffer, Hootsuite, native apps) for social. Context-switching killed velocity, proposals took hours of copy-paste, and social posts rarely tied back to the deal pipeline. The brief: collapse the stack into a single product.
Architecture
Backend is a NestJS monorepo split into bounded modules — crm, proposals, social, billing, webhooks. PostgreSQL is the system of record, Redis handles queues + rate-limit windows per provider, and BullMQ orchestrates background jobs (scheduled posts, inbound webhooks, proposal generation streaming). The frontend is Next.js 16 with React Server Components and a thin tRPC layer for type-safe contracts.
AI Proposal Engine
Proposals are generated from a client context graph — recent emails, meeting notes, deal stage, products of interest, and historical wins are stitched together with LangChain + RAG against a per-tenant vector store (pgvector). The LLM (OpenAI GPT-4o, with Gemini as fallback) drafts the proposal, scores tone, and suggests an upsell. Reps can:
- ▹Pick a template (SOW, retainer, fixed-bid, MSA)
- ▹Tune voice (formal / consultative / direct)
- ▹Re-prompt sections inline ("make the timeline more aggressive")
- ▹Export to PDF or send as a tracked link with view analytics
Social Media Suite
A single composer publishes natively to Twitter/X, LinkedIn (personal + company pages), Facebook Pages, Instagram (Reels, posts, stories). Each connection is OAuth2 with secure token refresh stored in AWS KMS.
Core capabilities:
- ▹Unified composer — write once, auto-truncate or auto-thread per platform character limits
- ▹Schedule queue — drag-drop calendar, time-zone aware, best-time-to-post recommender (AI)
- ▹Inbox — DMs and comments from every platform stream into one threaded view
- ▹AI assist — generate captions, hashtags, reply suggestions, repurpose long-form into a Twitter thread
- ▹Media library — S3-backed, with per-platform aspect-ratio auto-crop
- ▹Analytics — engagement, reach, click-through pulled via official Graph/Marketing APIs into a Recharts dashboard
AWS Infrastructure
- ▹ECS Fargate: stateless API + worker services, auto-scaled on queue depth
- ▹RDS (PostgreSQL + pgvector): relational data + embeddings co-located, single-tenant schemas via search_path
- ▹ElastiCache (Redis): BullMQ queues, rate-limit windows, session cache
- ▹S3 + CloudFront: media uploads, signed URLs for proposal PDFs
- ▹KMS: envelope-encryption for OAuth tokens and per-tenant secrets
- ▹EventBridge: scheduled jobs (token refresh, analytics roll-ups, draft expiry)
- ▹CloudWatch + OpenTelemetry: distributed tracing across NestJS + worker pods
Key Features
- ▹Multi-tenant CRM (contacts, deals, pipeline, activities, email sync)
- ▹AI proposal generator with RAG against tenant knowledge base
- ▹Native posting to Twitter/X, LinkedIn, Facebook, Instagram
- ▹Unified inbox: DMs + comments across all networks
- ▹Drag-drop content calendar with time-zone aware scheduling
- ▹Best-time-to-post recommender (trained per-account)
- ▹Approval workflows for enterprise teams
- ▹Webhook + Zapier integrations for inbound automation
- ▹Role-based permissions with full audit trail
- ▹Stripe-powered subscription billing
Outcome
- ▹Proposal turnaround dropped from ~3 hours to under 8 minutes on average
- ▹Customers consolidated 3–5 tools into one, cutting SaaS spend per seat
- ▹Social-to-deal attribution made it possible to credit pipeline back to marketing posts — a previously unmeasurable metric
- ▹Platform now handles thousands of scheduled posts per day across tenants with zero missed-publish incidents
Stack Snapshot
NestJS · Next.js 16 · TypeScript · PostgreSQL + pgvector · Redis · BullMQ · LangChain · OpenAI GPT-4o · Gemini · AWS ECS / RDS / S3 / KMS / EventBridge · Stripe · Twitter API v2 · LinkedIn Marketing API · Meta Graph API
Case Study — Challenges Faced & How They Were Solved
Building this platform meant solving a stack of problems that don't normally show up in greenfield CRMs. Below are the ones worth writing down — the rest got smoothed out in code review.
Challenge 1 — Streaming AI Proposals Without Locking the UI
Early proposal generation ran synchronously: hit the LLM, wait 30–60 seconds, return the full response. The UX was painful — a spinning loader on a deal worth $50k feels wrong, and users would refresh and re-fire the request, doubling the cost.
Solution: rebuilt the endpoint as a Server-Sent Events stream backed by NestJS controllers returning a ReadableStream. The LLM emits tokens, the backend forwards them over text/event-stream, and the Next.js client renders chunks as they arrive. A connection retry layer (Backoff with jitter) handles dropped streams without re-charging the API call. End result: the first token appears in under a second, and users see the proposal materialize in real time — same content, completely different perceived performance.
Challenge 2 — Multi-Platform Posting With Wildly Different APIs
Each social network treats "post a message" differently. Twitter v2 wants a JSON body and 280 chars. LinkedIn wants UGC posts with a person URN or organization URN — different endpoints, different shapes. Meta Graph API uses photo, video, reel, and story types each with their own upload pipeline (resumable upload sessions, container creation, then publish). Instagram has a two-step publish-with-container that fails silently if media isn't fully uploaded.
Solution: built a provider-agnostic facade at the service layer. Each platform implements the same SocialProvider interface (publish, fetchInbox, refreshToken), and a NormalizedPost type carries the platform-independent payload. Per-provider strategies handle the dialect differences: text auto-truncation for Twitter, multi-step container publishing for Instagram, organization-context resolution for LinkedIn company pages. Adding a new platform now means writing one class, not touching the rest of the codebase.
Challenge 3 — OAuth Token Storage & Refresh
Storing OAuth tokens for thousands of connected accounts is a liability surface. A single breach leaks production credentials for every customer's social presence. Refresh tokens expire silently in some flows (Twitter v2 access tokens last 2 hours and need to be refreshed continuously), and a failed refresh during a scheduled post means a missed publish.
Solution: envelope encryption with AWS KMS. Tokens are encrypted at rest using a per-tenant KMS data key; the encrypted ciphertext sits in PostgreSQL, the key never touches the application memory. A dedicated BullMQ refresh worker runs on a 30-minute cadence, refreshing any token within an hour of expiry. Failures route to a dead-letter queue that alerts the owning user via in-app notification: "reconnect your LinkedIn account."
Challenge 4 — Best-Time-to-Post Recommender
The naive version was "post at 9am Tuesday" — same for every account. Useless. Customers in different industries, time zones, and audience sizes had completely different optimal windows.
Solution: built a per-account engagement model. For every published post, the platform records reach and engagement metrics pulled from the official APIs at 1h, 6h, 24h, and 72h post-publish. A nightly batch job aggregates the last 90 days of data per account, computes engagement-per-hour heatmaps, and surfaces the top 3 recommended slots in the schedule UI. Cold-start accounts get industry-tier defaults; the model takes over once enough first-party data exists.
Challenge 5 — Unified Inbox Across Webhook & Polling Sources
LinkedIn doesn't have public webhooks for personal DMs. Twitter has them but with strict eligibility. Meta supports webhooks for Pages but the subscription flow is fragile and breaks silently. Stitching a real-time-feeling inbox out of this mess is harder than it sounds.
Solution: hybrid ingestion. Where webhooks exist (Meta Pages, Instagram), they fire into an SQS queue and stream into the inbox in real time. Where they don't (LinkedIn, sometimes Twitter), a polling worker runs at 60s intervals with exponential backoff if rate limits kick in. All inbound messages normalize into a single InboxMessage schema and surface in a threaded view that doesn't care about the underlying provider.
Challenge 6 — Tenant Isolation Without Schema-per-Tenant Overhead
Multi-tenant SaaS classic problem. Per-tenant database = clean isolation, terrible cost and migration story. Shared schema with tenant_id columns = cheap, but one missing WHERE clause and data leaks across customers.
Solution: row-level security in PostgreSQL combined with a NestJS interceptor that sets SET LOCAL app.tenant_id = '...' at the start of every request. RLS policies on every table enforce the boundary at the database layer — even a buggy query physically cannot return another tenant's rows. The application code reads as if there's no tenancy concern; the database enforces the invariant.
Challenge 7 — Cost Control on LLM Usage
LLM bills can spiral. A single chatty user generating 50 long proposals a day on GPT-4o costs more than the subscription they're paying. Without guardrails, the unit economics break.
Solution: per-tenant budget tracking at the gateway layer. Every LLM call is wrapped by a middleware that increments a Redis counter scoped to tenant:month. When the budget is 80% spent, the user sees a banner; at 100%, generation falls back to a smaller model (or hard-stops on enterprise plans with overage billing). Prompt caching on Anthropic and OpenAI cuts repeat-context costs by ~70% on long client-context graphs.
Challenge 8 — Workflow Approval for Enterprise Customers
Solo users hit publish and go. Marketing teams at larger customers need a "manager must approve before posting" gate, with a sane review UI and an audit trail.
Solution: a small state machine added to scheduled posts — draft → pending_review → approved | rejected → scheduled → published. Email + in-app notifications route to designated reviewers. Approvers see the rendered post-preview per platform side-by-side with the original draft, can comment inline, and approve or reject with a reason. Every transition is logged to an immutable audit table tied to user ID, timestamp, and IP.
What I'd Do Differently Next Time
- ▹Start with the provider facade on day one — I built it in retrofit during the LinkedIn integration. Adding it earlier would have saved a week.
- ▹Pin the LLM model version explicitly — silent model swaps from OpenAI shifted proposal tone mid-sprint. Now every prompt names the exact model version.
- ▹Use SSE end-to-end — including for inbox updates. Polling worked but kept adding latency where a server push would have been simpler.
- ▹Treat OAuth refresh as a first-class workflow, not an afterthought. Half the on-call pages in the first month were token refresh failures.
Outcome
The platform now ships proposals in under 8 minutes that used to take 3 hours, runs thousands of scheduled posts a day with zero missed publishes, and gives customers a single dashboard for what used to be 3–5 tools. The hardest part of building it wasn't the AI — it was the integration plumbing. The case study above is mostly the plumbing.