Developer Documentation
Status: Complete
Version: 5.0
Last Updated: 2024
Purpose: Comprehensive guide for developers working with NudgeCampaign
Table of Contents
- Getting Started
- Development Environment Setup
- Project Structure
- Architecture Overview
- Frontend Development
- Backend Development
- Database Schema
- API Development
- Testing Strategy
- Deployment Process
- Performance Optimization
- Security Guidelines
- 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 |
| 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
Request Flow
- Client Request β Next.js App Router
- Authentication β Supabase Auth verification
- Authorization β Role-based access control
- Business Logic β API route handlers
- Data Access β Prisma ORM with RLS
- 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
- Input Validation: Always validate and sanitize user input
- SQL Injection Prevention: Use parameterized queries (Prisma handles this)
- XSS Prevention: Sanitize HTML content, use React's built-in escaping
- CSRF Protection: Use CSRF tokens for state-changing operations
- Rate Limiting: Implement rate limiting on API endpoints
- Secrets Management: Never commit secrets, use environment variables
- HTTPS Only: Force HTTPS in production
- 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
- Fork & Clone: Fork the repository and clone locally
- Branch: Create a feature branch from
develop - Develop: Make your changes following our standards
- Test: Write and run tests for your changes
- Commit: Use conventional commits
- Push: Push to your fork
- 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!