Last updated: Aug 4, 2025, 11:26 AM UTC

Developer Documentation

Status: Complete
Version: 5.0
Last Updated: 2024
Purpose: Comprehensive guide for developers working with NudgeCampaign

Table of Contents

  1. Getting Started
  2. Development Environment Setup
  3. Project Structure
  4. Architecture Overview
  5. Frontend Development
  6. Backend Development
  7. Database Schema
  8. API Development
  9. Testing Strategy
  10. Deployment Process
  11. Performance Optimization
  12. Security Guidelines
  13. Contributing Guidelines

Getting Started

Welcome to the NudgeCampaign developer documentation. This guide provides everything you need to develop, test, and deploy features for our AI-powered email marketing platform.

Prerequisites

Before you begin, ensure you have the following installed:

  • Node.js (v20.x LTS or higher)
  • npm (v10.x or higher) or yarn (v1.22.x)
  • Docker and Docker Compose (latest stable)
  • Git (v2.30 or higher)
  • PostgreSQL client tools
  • Redis client tools
  • VS Code or your preferred IDE

Quick Start

Clone the repository and get running in minutes:

# Clone the repository
git clone https://github.com/nudgecampaign/nudgecampaign.git
cd nudgecampaign

# Copy environment variables
cp .env.example .env.local

# Install dependencies
npm install

# Start Docker services (PostgreSQL, Redis)
docker-compose up -d

# Run database migrations
npm run db:migrate

# Seed development data
npm run db:seed

# Start development server
npm run dev

# Open http://localhost:3000

Technology Stack

Our technology choices prioritize developer experience, performance, and maintainability:

Layer Technology Purpose
Frontend Next.js 14 React framework with SSR/SSG
UI Library React 18 Component-based UI
Styling Tailwind CSS Utility-first CSS
Components shadcn/ui Accessible component library
State Zustand Lightweight state management
Backend Node.js 20 JavaScript runtime
API Express.js Web framework
ORM Prisma 5 Type-safe database client
Database PostgreSQL 14 Primary data store
Cache Redis 7 Session and data cache
Queue Bull Job queue management
Email Postmark Transactional email
Payments Stripe Subscription billing
AI OpenRouter LLM integration
Auth Supabase Auth Authentication service
Testing Jest/Playwright Unit and E2E testing
CI/CD GitHub Actions Automated workflows

Development Environment Setup

Local Development with Docker

Our Docker setup provides a consistent development environment:

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:14-alpine
    environment:
      POSTGRES_USER: nudgecampaign
      POSTGRES_PASSWORD: localdev
      POSTGRES_DB: nudgecampaign_dev
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  mailhog:
    image: mailhog/mailhog
    ports:
      - "1025:1025" # SMTP
      - "8025:8025" # Web UI

volumes:
  postgres_data:
  redis_data:

Start services:

# Start all services
docker-compose up -d

# View logs
docker-compose logs -f

# Stop services
docker-compose down

# Reset data (warning: deletes all data)
docker-compose down -v

Environment Variables

Configure your .env.local file:

# Application
NODE_ENV=development
APP_URL=http://localhost:3000
PORT=3000

# Database
DATABASE_URL=postgresql://nudgecampaign:localdev@localhost:5432/nudgecampaign_dev
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10

# Redis
REDIS_URL=redis://localhost:6379
REDIS_PREFIX=nc:

# Authentication (Supabase)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx

# Email (Postmark)
POSTMARK_SERVER_TOKEN=xxx
POSTMARK_FROM_EMAIL=hello@nudgecampaign.com
POSTMARK_MESSAGE_STREAM=outbound

# Payments (Stripe)
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

# AI (OpenRouter)
OPENROUTER_API_KEY=sk-or-v1-xxx
OPENROUTER_MODEL=anthropic/claude-3-opus

# Security
JWT_SECRET=your-secret-key-here
ENCRYPTION_KEY=32-character-encryption-key-here

# Feature Flags
FEATURE_AI_CHAT=true
FEATURE_AUTOMATIONS=true
FEATURE_AB_TESTING=true

VS Code Configuration

Recommended VS Code settings for the project:

// .vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "tailwindCSS.experimental.classRegex": [
    ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
  ]
}

Recommended extensions:

// .vscode/extensions.json
{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "bradlc.vscode-tailwindcss",
    "prisma.prisma",
    "github.copilot",
    "eamodio.gitlens",
    "formulahendry.auto-rename-tag",
    "christian-kohler.path-intellisense"
  ]
}

Project Structure

Our project follows a modular, domain-driven structure:

nudgecampaign/
β”œβ”€β”€ .github/                 # GitHub Actions workflows
β”‚   └── workflows/
β”œβ”€β”€ .vscode/                 # VS Code configuration
β”œβ”€β”€ docs/                    # Documentation
β”‚   └── 20-support-materials/
β”œβ”€β”€ prisma/                  # Database schema and migrations
β”‚   β”œβ”€β”€ schema.prisma
β”‚   β”œβ”€β”€ migrations/
β”‚   └── seed.ts
β”œβ”€β”€ public/                  # Static assets
β”‚   β”œβ”€β”€ images/
β”‚   └── fonts/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/                # Next.js app directory
β”‚   β”‚   β”œβ”€β”€ (auth)/        # Auth routes
β”‚   β”‚   β”œβ”€β”€ (dashboard)/   # Dashboard routes
β”‚   β”‚   β”œβ”€β”€ api/           # API routes
β”‚   β”‚   β”œβ”€β”€ layout.tsx
β”‚   β”‚   └── page.tsx
β”‚   β”œβ”€β”€ components/         # React components
β”‚   β”‚   β”œβ”€β”€ ui/           # Base UI components
β”‚   β”‚   β”œβ”€β”€ forms/        # Form components
β”‚   β”‚   β”œβ”€β”€ layouts/      # Layout components
β”‚   β”‚   └── features/     # Feature components
β”‚   β”œβ”€β”€ lib/               # Utility libraries
β”‚   β”‚   β”œβ”€β”€ api/          # API clients
β”‚   β”‚   β”œβ”€β”€ auth/         # Auth utilities
β”‚   β”‚   β”œβ”€β”€ db/           # Database utilities
β”‚   β”‚   β”œβ”€β”€ email/        # Email utilities
β”‚   β”‚   └── utils/        # General utilities
β”‚   β”œβ”€β”€ hooks/             # React hooks
β”‚   β”œβ”€β”€ stores/            # Zustand stores
β”‚   β”œβ”€β”€ types/             # TypeScript types
β”‚   └── styles/            # Global styles
β”œβ”€β”€ tests/                  # Test files
β”‚   β”œβ”€β”€ unit/
β”‚   β”œβ”€β”€ integration/
β”‚   └── e2e/
β”œβ”€β”€ scripts/               # Build and utility scripts
β”œβ”€β”€ .env.example          # Environment variables template
β”œβ”€β”€ .eslintrc.json        # ESLint configuration
β”œβ”€β”€ .prettierrc           # Prettier configuration
β”œβ”€β”€ docker-compose.yml    # Docker services
β”œβ”€β”€ next.config.js        # Next.js configuration
β”œβ”€β”€ package.json          # Dependencies
β”œβ”€β”€ tailwind.config.ts    # Tailwind configuration
└── tsconfig.json         # TypeScript configuration

Key Directories Explained

/src/app - Next.js App Router

The app directory uses Next.js 14's app router with file-based routing:

// src/app/campaigns/page.tsx
import { Metadata } from 'next'
import { CampaignList } from '@/components/features/campaigns'

export const metadata: Metadata = {
  title: 'Campaigns | NudgeCampaign',
  description: 'Manage your email campaigns'
}

export default async function CampaignsPage() {
  return <CampaignList />
}

/src/components - Component Library

Components follow atomic design principles:

// src/components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "underline-offset-4 hover:underline text-primary"
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 rounded-md",
        lg: "h-11 px-8 rounded-md",
        icon: "h-10 w-10"
      }
    },
    defaultVariants: {
      variant: "default",
      size: "default"
    }
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

/src/lib - Core Libraries

Utility functions and service integrations:

// src/lib/email/postmark.ts
import { ServerClient } from 'postmark'

const client = new ServerClient(process.env.POSTMARK_SERVER_TOKEN!)

export async function sendEmail({
  to,
  subject,
  html,
  text,
  from = process.env.POSTMARK_FROM_EMAIL!,
  replyTo,
  tags = [],
  metadata = {}
}: EmailOptions) {
  try {
    const result = await client.sendEmail({
      From: from,
      To: to,
      Subject: subject,
      HtmlBody: html,
      TextBody: text,
      ReplyTo: replyTo,
      Tag: tags[0],
      Metadata: metadata,
      MessageStream: 'outbound'
    })
    
    return {
      success: true,
      messageId: result.MessageID,
      submittedAt: result.SubmittedAt
    }
  } catch (error) {
    console.error('Postmark error:', error)
    return {
      success: false,
      error: error.message
    }
  }
}

Architecture Overview

System Architecture Diagram

graph TB subgraph "Client Layer" Browser[Web Browser] Mobile[Mobile App] end subgraph "Application Layer" NextJS[Next.js App] API[API Routes] SSR[SSR/SSG] end subgraph "Service Layer" Auth[Auth Service] Email[Email Service] Queue[Job Queue] AI[AI Service] end subgraph "Data Layer" Postgres[(PostgreSQL)] Redis[(Redis Cache)] S3[S3 Storage] end Browser --> NextJS Mobile --> API NextJS --> SSR NextJS --> API API --> Auth API --> Email API --> Queue API --> AI API --> Postgres API --> Redis Queue --> Email

Request Flow

  1. Client Request β†’ Next.js App Router
  2. Authentication β†’ Supabase Auth verification
  3. Authorization β†’ Role-based access control
  4. Business Logic β†’ API route handlers
  5. Data Access β†’ Prisma ORM with RLS
  6. Response β†’ JSON or Server Component

Multi-Tenant Architecture

Every request is scoped to an organization:

// src/lib/auth/organization.ts
export async function getCurrentOrganization(userId: string) {
  const org = await prisma.organizationMember.findFirst({
    where: { userId },
    include: { organization: true }
  })
  
  if (!org) throw new Error('No organization found')
  
  return org.organization
}

// Middleware to inject organization context
export function withOrganization(handler: NextApiHandler) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    const session = await getServerSession(req, res, authOptions)
    if (!session) return res.status(401).json({ error: 'Unauthorized' })
    
    const organization = await getCurrentOrganization(session.user.id)
    req.organization = organization
    
    return handler(req, res)
  }
}

Frontend Development

Component Development

We use shadcn/ui as our component foundation with custom features:

// src/components/features/campaigns/CampaignCard.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { MoreHorizontal, Send, Edit, Trash } from 'lucide-react'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'

interface CampaignCardProps {
  campaign: Campaign
  onEdit: (id: string) => void
  onDelete: (id: string) => void
  onSend: (id: string) => void
}

export function CampaignCard({ campaign, onEdit, onDelete, onSend }: CampaignCardProps) {
  const statusColor = {
    draft: 'bg-gray-500',
    scheduled: 'bg-blue-500',
    sending: 'bg-yellow-500',
    sent: 'bg-green-500',
    failed: 'bg-red-500'
  }[campaign.status]

  return (
    <Card className="hover:shadow-lg transition-shadow">
      <CardHeader>
        <div className="flex items-center justify-between">
          <CardTitle className="text-lg">{campaign.name}</CardTitle>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="icon">
                <MoreHorizontal className="h-4 w-4" />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem onClick={() => onEdit(campaign.id)}>
                <Edit className="mr-2 h-4 w-4" />
                Edit
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => onSend(campaign.id)}>
                <Send className="mr-2 h-4 w-4" />
                Send
              </DropdownMenuItem>
              <DropdownMenuItem 
                onClick={() => onDelete(campaign.id)}
                className="text-red-600"
              >
                <Trash className="mr-2 h-4 w-4" />
                Delete
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
        <CardDescription>{campaign.subject}</CardDescription>
      </CardHeader>
      <CardContent>
        <div className="flex items-center justify-between">
          <Badge className={statusColor}>
            {campaign.status}
          </Badge>
          <div className="text-sm text-muted-foreground">
            {campaign.recipientCount} recipients
          </div>
        </div>
        {campaign.stats && (
          <div className="mt-4 grid grid-cols-3 gap-2 text-sm">
            <div>
              <span className="text-muted-foreground">Opened:</span>
              <span className="ml-1 font-medium">{campaign.stats.openRate}%</span>
            </div>
            <div>
              <span className="text-muted-foreground">Clicked:</span>
              <span className="ml-1 font-medium">{campaign.stats.clickRate}%</span>
            </div>
            <div>
              <span className="text-muted-foreground">Bounced:</span>
              <span className="ml-1 font-medium">{campaign.stats.bounceRate}%</span>
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  )
}

State Management with Zustand

// src/stores/campaignStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface CampaignState {
  campaigns: Campaign[]
  loading: boolean
  error: string | null
  
  // Actions
  fetchCampaigns: () => Promise<void>
  createCampaign: (data: CreateCampaignDTO) => Promise<Campaign>
  updateCampaign: (id: string, data: UpdateCampaignDTO) => Promise<void>
  deleteCampaign: (id: string) => Promise<void>
}

export const useCampaignStore = create<CampaignState>()(
  devtools(
    persist(
      (set, get) => ({
        campaigns: [],
        loading: false,
        error: null,

        fetchCampaigns: async () => {
          set({ loading: true, error: null })
          try {
            const response = await fetch('/api/campaigns')
            const data = await response.json()
            set({ campaigns: data, loading: false })
          } catch (error) {
            set({ error: error.message, loading: false })
          }
        },

        createCampaign: async (data) => {
          set({ loading: true, error: null })
          try {
            const response = await fetch('/api/campaigns', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(data)
            })
            const campaign = await response.json()
            set(state => ({
              campaigns: [...state.campaigns, campaign],
              loading: false
            }))
            return campaign
          } catch (error) {
            set({ error: error.message, loading: false })
            throw error
          }
        },

        updateCampaign: async (id, data) => {
          set({ loading: true, error: null })
          try {
            await fetch(`/api/campaigns/${id}`, {
              method: 'PUT',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(data)
            })
            set(state => ({
              campaigns: state.campaigns.map(c => 
                c.id === id ? { ...c, ...data } : c
              ),
              loading: false
            }))
          } catch (error) {
            set({ error: error.message, loading: false })
            throw error
          }
        },

        deleteCampaign: async (id) => {
          set({ loading: true, error: null })
          try {
            await fetch(`/api/campaigns/${id}`, {
              method: 'DELETE'
            })
            set(state => ({
              campaigns: state.campaigns.filter(c => c.id !== id),
              loading: false
            }))
          } catch (error) {
            set({ error: error.message, loading: false })
            throw error
          }
        }
      }),
      {
        name: 'campaign-storage',
        partialize: (state) => ({ campaigns: state.campaigns })
      }
    )
  )
)

Custom Hooks

// src/hooks/useDebounce.ts
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}

// src/hooks/useInfiniteScroll.ts
export function useInfiniteScroll(callback: () => void) {
  const observer = useRef<IntersectionObserver>()
  const lastElementRef = useCallback(
    (node: HTMLElement | null) => {
      if (observer.current) observer.current.disconnect()
      observer.current = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting) {
          callback()
        }
      })
      if (node) observer.current.observe(node)
    },
    [callback]
  )

  return lastElementRef
}

Backend Development

API Route Handlers

Next.js 14 API routes with TypeScript:

// src/app/api/campaigns/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/db'
import { campaignSchema } from '@/lib/validations/campaign'
import { authOptions } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '10')
  const status = searchParams.get('status')

  try {
    const where = {
      organizationId: session.user.organizationId,
      ...(status && { status })
    }

    const [campaigns, total] = await Promise.all([
      prisma.campaign.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
        include: {
          _count: {
            select: { recipients: true }
          }
        }
      }),
      prisma.campaign.count({ where })
    ])

    return NextResponse.json({
      data: campaigns,
      meta: {
        total,
        page,
        limit,
        pages: Math.ceil(total / limit)
      }
    })
  } catch (error) {
    console.error('Campaign fetch error:', error)
    return NextResponse.json(
      { error: 'Failed to fetch campaigns' },
      { status: 500 }
    )
  }
}

export async function POST(request: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    const validated = campaignSchema.parse(body)

    const campaign = await prisma.campaign.create({
      data: {
        ...validated,
        organizationId: session.user.organizationId,
        userId: session.user.id,
        status: 'draft'
      }
    })

    // Queue email preparation job
    await queueJob('prepareCampaign', { campaignId: campaign.id })

    return NextResponse.json(campaign, { status: 201 })
  } catch (error) {
    if (error.name === 'ZodError') {
      return NextResponse.json(
        { error: 'Validation failed', details: error.errors },
        { status: 400 }
      )
    }
    
    console.error('Campaign creation error:', error)
    return NextResponse.json(
      { error: 'Failed to create campaign' },
      { status: 500 }
    )
  }
}

Background Jobs with Bull

// src/lib/queue/campaignQueue.ts
import Queue from 'bull'
import { sendCampaignEmail } from '@/lib/email/campaigns'

export const campaignQueue = new Queue('campaigns', {
  redis: {
    host: process.env.REDIS_HOST,
    port: process.env.REDIS_PORT,
    password: process.env.REDIS_PASSWORD
  }
})

// Process campaign sending
campaignQueue.process('send-campaign', async (job) => {
  const { campaignId, recipientId } = job.data
  
  const campaign = await prisma.campaign.findUnique({
    where: { id: campaignId },
    include: { recipient: true }
  })
  
  if (!campaign) throw new Error('Campaign not found')
  
  // Send email via Postmark
  const result = await sendCampaignEmail(campaign, recipient)
  
  // Update delivery status
  await prisma.emailDelivery.create({
    data: {
      campaignId,
      contactId: recipientId,
      messageId: result.messageId,
      status: 'sent',
      sentAt: new Date()
    }
  })
  
  return result
})

// Handle job completion
campaignQueue.on('completed', (job, result) => {
  console.log(`Campaign email sent: ${result.messageId}`)
})

// Handle job failure
campaignQueue.on('failed', (job, err) => {
  console.error(`Campaign job failed: ${err.message}`)
  // Send to error tracking service
})

Middleware

// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request })
  const isAuthPage = request.nextUrl.pathname.startsWith('/login') ||
                     request.nextUrl.pathname.startsWith('/register')
  
  if (isAuthPage) {
    if (token) {
      return NextResponse.redirect(new URL('/dashboard', request.url))
    }
    return NextResponse.next()
  }
  
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  
  // Add organization context to headers
  if (token.organizationId) {
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-organization-id', token.organizationId)
    
    return NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    })
  }
  
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}

Database Schema

Prisma Schema Definition

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Organization {
  id        String   @id @default(cuid())
  name      String
  slug      String   @unique
  domain    String?
  settings  Json     @default("{}")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  members     OrganizationMember[]
  contacts    Contact[]
  campaigns   Campaign[]
  templates   Template[]
  automations Automation[]
  
  @@map("organizations")
}

model OrganizationMember {
  id             String       @id @default(cuid())
  organizationId String
  userId         String
  role           Role         @default(MEMBER)
  joinedAt       DateTime     @default(now())
  
  organization Organization @relation(fields: [organizationId], references: [id])
  user         User         @relation(fields: [userId], references: [id])
  
  @@unique([organizationId, userId])
  @@map("organization_members")
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  avatar        String?
  lastLoginAt   DateTime?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  
  organizations OrganizationMember[]
  campaigns     Campaign[]
  conversations AIConversation[]
  
  @@map("users")
}

model Contact {
  id             String   @id @default(cuid())
  organizationId String
  email          String
  firstName      String?
  lastName       String?
  status         ContactStatus @default(SUBSCRIBED)
  tags           String[]
  customFields   Json     @default("{}")
  subscribedAt   DateTime @default(now())
  unsubscribedAt DateTime?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  
  organization Organization @relation(fields: [organizationId], references: [id])
  deliveries   EmailDelivery[]
  
  @@unique([organizationId, email])
  @@index([organizationId, status])
  @@map("contacts")
}

model Campaign {
  id             String         @id @default(cuid())
  organizationId String
  userId         String
  name           String
  subject        String
  fromName       String
  fromEmail      String
  replyTo        String?
  htmlContent    String
  textContent    String?
  status         CampaignStatus @default(DRAFT)
  scheduledAt    DateTime?
  sentAt         DateTime?
  settings       Json           @default("{}")
  stats          Json?
  createdAt      DateTime       @default(now())
  updatedAt      DateTime       @updatedAt
  
  organization Organization    @relation(fields: [organizationId], references: [id])
  user         User           @relation(fields: [userId], references: [id])
  deliveries   EmailDelivery[]
  
  @@index([organizationId, status])
  @@map("campaigns")
}

enum Role {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

enum ContactStatus {
  SUBSCRIBED
  UNSUBSCRIBED
  BOUNCED
  COMPLAINED
}

enum CampaignStatus {
  DRAFT
  SCHEDULED
  SENDING
  SENT
  CANCELLED
}

Database Migrations

# Create a new migration
npx prisma migrate dev --name add_custom_fields

# Apply migrations to production
npx prisma migrate deploy

# Reset database (development only)
npx prisma migrate reset

# Generate Prisma Client
npx prisma generate

# Open Prisma Studio
npx prisma studio

Row Level Security (RLS)

-- Enable RLS on all tables
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;

-- Policy for contacts table
CREATE POLICY "Users can only see their organization's contacts"
  ON contacts
  FOR ALL
  USING (organization_id = current_setting('app.organization_id')::uuid);

-- Function to set organization context
CREATE OR REPLACE FUNCTION set_organization_context(org_id uuid)
RETURNS void AS $
BEGIN
  PERFORM set_config('app.organization_id', org_id::text, true);
END;
$ LANGUAGE plpgsql;

API Development

RESTful API Design

Follow REST principles and conventions:

// API Routes Structure
GET    /api/campaigns        # List campaigns
GET    /api/campaigns/:id    # Get campaign
POST   /api/campaigns        # Create campaign
PUT    /api/campaigns/:id    # Update campaign
DELETE /api/campaigns/:id    # Delete campaign

POST   /api/campaigns/:id/send      # Send campaign
POST   /api/campaigns/:id/duplicate # Duplicate campaign
GET    /api/campaigns/:id/analytics # Get analytics

API Validation with Zod

// src/lib/validations/campaign.ts
import { z } from 'zod'

export const campaignSchema = z.object({
  name: z.string().min(1).max(255),
  subject: z.string().min(1).max(255),
  fromName: z.string().min(1).max(100),
  fromEmail: z.string().email(),
  replyTo: z.string().email().optional(),
  htmlContent: z.string().min(1),
  textContent: z.string().optional(),
  recipientListId: z.string().uuid(),
  templateId: z.string().uuid().optional(),
  settings: z.object({
    trackOpens: z.boolean().default(true),
    trackClicks: z.boolean().default(true),
    googleAnalytics: z.boolean().default(false)
  }).optional()
})

export type CampaignInput = z.infer<typeof campaignSchema>

Error Handling

Consistent error responses:

// src/lib/api/errors.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public details?: any
  ) {
    super(message)
  }
}

export function handleApiError(error: unknown): NextResponse {
  console.error('API Error:', error)
  
  if (error instanceof ApiError) {
    return NextResponse.json(
      {
        error: error.message,
        details: error.details
      },
      { status: error.statusCode }
    )
  }
  
  if (error instanceof z.ZodError) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: error.errors
      },
      { status: 400 }
    )
  }
  
  return NextResponse.json(
    { error: 'Internal server error' },
    { status: 500 }
  )
}

Testing Strategy

Unit Testing with Jest

// tests/unit/lib/email.test.ts
import { renderEmailTemplate } from '@/lib/email/template'

describe('Email Template Rendering', () => {
  it('should replace merge tags correctly', () => {
    const template = 'Hello {{firstName}} {{lastName}}!'
    const data = { firstName: 'John', lastName: 'Doe' }
    
    const result = renderEmailTemplate(template, data)
    
    expect(result).toBe('Hello John Doe!')
  })
  
  it('should handle missing merge tags', () => {
    const template = 'Hello {{firstName}} {{lastName}}!'
    const data = { firstName: 'John' }
    
    const result = renderEmailTemplate(template, data)
    
    expect(result).toBe('Hello John !')
  })
})

Integration Testing

// tests/integration/api/campaigns.test.ts
import { createMocks } from 'node-mocks-http'
import handler from '@/app/api/campaigns/route'
import { prisma } from '@/lib/db'

describe('/api/campaigns', () => {
  beforeEach(async () => {
    await prisma.campaign.deleteMany()
  })
  
  describe('GET /api/campaigns', () => {
    it('should return campaigns for authenticated user', async () => {
      const { req, res } = createMocks({
        method: 'GET',
        headers: {
          authorization: 'Bearer valid-token'
        }
      })
      
      await handler(req, res)
      
      expect(res._getStatusCode()).toBe(200)
      const data = JSON.parse(res._getData())
      expect(data).toHaveProperty('data')
      expect(data).toHaveProperty('meta')
    })
    
    it('should return 401 for unauthenticated request', async () => {
      const { req, res } = createMocks({
        method: 'GET'
      })
      
      await handler(req, res)
      
      expect(res._getStatusCode()).toBe(401)
    })
  })
})

E2E Testing with Playwright

// tests/e2e/campaigns.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Campaign Management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'password')
    await page.click('button[type="submit"]')
    await page.waitForURL('/dashboard')
  })
  
  test('should create a new campaign', async ({ page }) => {
    await page.goto('/campaigns')
    await page.click('text=New Campaign')
    
    await page.fill('[name="name"]', 'Test Campaign')
    await page.fill('[name="subject"]', 'Test Subject')
    await page.fill('.email-editor', '<p>Test content</p>')
    
    await page.click('text=Save Campaign')
    
    await expect(page.locator('.toast')).toContainText('Campaign created')
    await expect(page.locator('h1')).toContainText('Test Campaign')
  })
  
  test('should send test email', async ({ page }) => {
    await page.goto('/campaigns/test-campaign')
    await page.click('text=Send Test')
    
    await page.fill('[name="testEmail"]', 'test@example.com')
    await page.click('text=Send Test Email')
    
    await expect(page.locator('.toast')).toContainText('Test email sent')
  })
})

Test Configuration

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  transform: {
    '^.+\\.tsCODE_BLOCK_30#39;: 'ts-jest',
  },
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/*.stories.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}

Deployment Process

Production Build

# Build for production
npm run build

# Analyze bundle size
npm run analyze

# Run production server
npm start

Docker Deployment

# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test
      - run: npm run lint
      - run: npm run type-check

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID }}
          vercel-project-id: ${{ secrets.PROJECT_ID }}
          vercel-args: '--prod'

Performance Optimization

Next.js Optimization

// next.config.js
module.exports = {
  images: {
    domains: ['cdn.nudgecampaign.com'],
    formats: ['image/avif', 'image/webp'],
  },
  compress: true,
  poweredByHeader: false,
  reactStrictMode: true,
  swcMinify: true,
  experimental: {
    optimizeCss: true,
  },
}

Database Query Optimization

// Use select to limit fields
const campaigns = await prisma.campaign.findMany({
  select: {
    id: true,
    name: true,
    status: true,
    createdAt: true,
    _count: {
      select: { recipients: true }
    }
  }
})

// Use pagination
const campaigns = await prisma.campaign.findMany({
  skip: (page - 1) * limit,
  take: limit
})

// Use database indexes
// In schema.prisma:
// @@index([organizationId, status])

Caching Strategy

// Redis caching
import { redis } from '@/lib/redis'

export async function getCachedCampaigns(orgId: string) {
  const cacheKey = `campaigns:${orgId}`
  
  // Try cache first
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)
  
  // Fetch from database
  const campaigns = await prisma.campaign.findMany({
    where: { organizationId: orgId }
  })
  
  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, JSON.stringify(campaigns))
  
  return campaigns
}

Security Guidelines

Security Best Practices

  1. Input Validation: Always validate and sanitize user input
  2. SQL Injection Prevention: Use parameterized queries (Prisma handles this)
  3. XSS Prevention: Sanitize HTML content, use React's built-in escaping
  4. CSRF Protection: Use CSRF tokens for state-changing operations
  5. Rate Limiting: Implement rate limiting on API endpoints
  6. Secrets Management: Never commit secrets, use environment variables
  7. HTTPS Only: Force HTTPS in production
  8. Content Security Policy: Implement CSP headers

Security Headers

// middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next()
  
  // Security headers
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('X-XSS-Protection', '1; mode=block')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
  )
  
  return response
}

Contributing Guidelines

Development Workflow

  1. Fork & Clone: Fork the repository and clone locally
  2. Branch: Create a feature branch from develop
  3. Develop: Make your changes following our standards
  4. Test: Write and run tests for your changes
  5. Commit: Use conventional commits
  6. Push: Push to your fork
  7. PR: Open a pull request to develop

Commit Convention

# Format: <type>(<scope>): <subject>

feat(campaigns): add email scheduling
fix(auth): resolve session timeout issue
docs(api): update endpoint documentation
test(contacts): add import validation tests
refactor(ui): simplify button component
style(dashboard): fix layout spacing
chore(deps): update dependencies

Code Review Checklist

  • Code follows style guidelines
  • Tests pass and coverage maintained
  • Documentation updated
  • No console.logs or debugging code
  • Security considerations addressed
  • Performance impact assessed
  • Accessibility requirements met
  • Mobile responsiveness verified

Conclusion

This developer documentation provides a comprehensive guide to building and maintaining NudgeCampaign. Follow these guidelines to ensure code quality, performance, and security. For questions or suggestions, please open an issue on GitHub or contact the development team.

Remember to keep this documentation updated as the codebase evolves. Happy coding!