changemaker.lite/mkdocs/docs/v2/migration/breaking-changes.md

29 KiB

Breaking Changes: V1 to V2

This document comprehensively details all breaking changes between Changemaker Lite V1 and V2. Review this carefully before migration to understand required code changes, configuration updates, and data transformations.

Overview

V2 is a clean-room rebuild with fundamental architectural changes. Almost every aspect of the platform has changed, requiring careful planning for migration.

!!! danger "Not a Drop-In Replacement" V2 cannot be deployed alongside V1 without migration. Database schemas, APIs, and authentication are completely incompatible.

Major Architectural Changes

1. Application Structure

V1 Architecture:

changemaker.lite/
├── influence/          # Separate Express app (port 3333)
│   ├── app.js
│   ├── routes/
│   └── views/ (EJS)
├── map/                # Separate Express app (port 3000)
│   ├── app.js
│   ├── routes/
│   └── views/ (EJS)
└── docker-compose.yml

V2 Architecture:

changemaker.lite/
├── api/                # Unified Express + Fastify (ports 4000, 4100)
│   ├── src/server.ts          # Express main API
│   ├── src/media-server.ts    # Fastify media API
│   └── prisma/schema.prisma
├── admin/              # React SPA (port 3000)
│   └── src/
└── docker-compose.yml

Impact: V1 had two separate codebases with duplicated auth, middleware, and configuration. V2 consolidates everything into a single unified API.

2. Data Layer Transformation

Aspect V1 V2
ORM None (direct NocoDB REST API) Prisma ORM + Drizzle (media)
Database NocoDB internal PostgreSQL PostgreSQL 16 direct access
Migrations NocoDB auto-migrations Prisma migrate
Validation Manual (express-validator) Zod schemas
Queries HTTP requests to NocoDB prisma.model.findMany()

V1 Example (NocoDB REST API):

// influence/routes/campaigns.js
const campaigns = await axios.get('http://nocodb:8080/api/v1/db/data/v1/campaigns', {
  headers: { 'xc-token': process.env.NOCODB_API_TOKEN }
});

V2 Example (Prisma ORM):

// api/src/modules/influence/campaigns/campaigns.service.ts
const campaigns = await prisma.campaign.findMany({
  where: { active: true },
  include: { createdBy: true }
});

Impact: All database queries must be rewritten from HTTP requests to Prisma queries. No migration script can automate this.

3. Authentication System

Session-Based (V1) → JWT (V2)

V1 Authentication:

// Session cookies + express-session + Redis store
app.use(session({
  store: redisStore,
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
}));

// Login sets session
req.session.userId = user.id;
req.session.role = user.role;

V2 Authentication:

// JWT access + refresh tokens
const accessToken = jwt.sign(
  { id: user.id, email: user.email, role: user.role },
  env.JWT_ACCESS_SECRET,
  { expiresIn: '15m' }
);

const refreshToken = jwt.sign(
  { id: user.id },
  env.JWT_REFRESH_SECRET,
  { expiresIn: '7d' }
);

// Store refresh token in database (rotation on use)
await prisma.refreshToken.create({
  data: { token: refreshToken, userId: user.id }
});

Impact:

  • V1 sessions do not migrate. All users must re-login after V2 deployment.
  • Frontend must be rewritten to store JWT tokens (localStorage/sessionStorage).
  • API requests must include Authorization: Bearer <token> header instead of cookies.

Password Hashing

Compatibility: Both V1 and V2 use bcrypt, so password hashes can migrate directly.

// V1 (influence/routes/auth.js)
const hashedPassword = await bcrypt.hash(password, 10);

// V2 (api/src/modules/auth/auth.service.ts)
const hashedPassword = await bcrypt.hash(password, 10);

// Comparison works
await bcrypt.compare(inputPassword, migratedHash); // ✅ Works

!!! success "Password Migration Safe" V1 bcrypt hashes can be copied directly to V2 User.password field. Users can login with existing passwords.

4. User Model Changes

V1 User Models (Separate Tables)

Influence Users (influence_users table):

{
  "id": 1,
  "email": "admin@example.com",
  "password": "$2b$10...",
  "role": "admin"
}

Login Users (login table):

{
  "id": 1,
  "email": "admin@example.com",
  "password": "$2b$10...",
  "name": "Admin User"
}

Problem: V1 had two separate user tables (one per app) with potential email duplicates.

V2 User Model (Unified)

model User {
  id            String          @id @default(cuid())
  email         String          @unique  // Enforced unique
  password      String
  name          String?
  phone         String?
  role          UserRole        @default(USER)
  status        UserStatus      @default(ACTIVE)
  createdVia    UserCreatedVia  @default(STANDARD)
  expiresAt     DateTime?
  emailVerified Boolean         @default(false)
  createdAt     DateTime        @default(now())
  updatedAt     DateTime        @updatedAt
}

enum UserRole {
  SUPER_ADMIN
  INFLUENCE_ADMIN
  MAP_ADMIN
  USER
  TEMP
}

Migration Challenges:

  1. Email deduplication: Merge influence_users + login where email matches
  2. Role mapping: V1 "admin" → V2 SUPER_ADMIN, V1 "user" → V2 USER
  3. Missing fields: V2 adds phone, status, createdVia, emailVerified
  4. ID format: V1 integer IDs → V2 CUID strings (breaks foreign keys)

Migration Script (conceptual):

// Merge V1 users into V2
const v1InfluenceUsers = await fetchFromNocoDB('influence_users');
const v1LoginUsers = await fetchFromNocoDB('login');

const mergedUsers = mergeByEmail(v1InfluenceUsers, v1LoginUsers);

for (const user of mergedUsers) {
  await prisma.user.create({
    data: {
      email: user.email,
      password: user.password, // bcrypt hash migrates directly
      name: user.name || null,
      role: mapRole(user.role), // 'admin' → 'SUPER_ADMIN'
      createdAt: user.created_at || new Date()
    }
  });
}

5. Frontend Stack

Aspect V1 V2
Framework Server-rendered EJS React 19 SPA
Build Tool None (direct EJS rendering) Vite 5
UI Library Bootstrap 4 Ant Design 5
State Management Server session Zustand stores
Routing Express routes (server-side) React Router (client-side)
Styling CSS + Bootstrap CSS Modules + Ant Design tokens

V1 View (EJS template):

<!-- influence/views/campaigns.ejs -->
<% campaigns.forEach(campaign => { %>
  <div class="card">
    <h3><%= campaign.title %></h3>
    <p><%= campaign.description %></p>
  </div>
<% }) %>

V2 Component (React + TypeScript):

// admin/src/pages/CampaignsPage.tsx
const CampaignsPage = () => {
  const [campaigns, setCampaigns] = useState<Campaign[]>([]);

  useEffect(() => {
    api.get('/api/influence/campaigns').then(res => {
      setCampaigns(res.data.data);
    });
  }, []);

  return (
    <Table dataSource={campaigns} columns={columns} />
  );
};

Impact:

  • V1 views cannot be reused. All UI must be rewritten in React.
  • Client-side routing requires API design (RESTful endpoints).
  • State management shifts from server (session) to client (Zustand).

API Changes

Endpoint URL Structure

V1 Endpoints:

# Influence app (port 3333)
GET  /campaigns
POST /campaigns/create
GET  /campaigns/:id/edit
POST /representatives/lookup

# Map app (port 3000)
GET  /locations
POST /locations/create
GET  /shifts

V2 Endpoints:

# Unified API (port 4000)
GET    /api/influence/campaigns
POST   /api/influence/campaigns
GET    /api/influence/campaigns/:id
PUT    /api/influence/campaigns/:id
DELETE /api/influence/campaigns/:id
POST   /api/influence/representatives/lookup

GET    /api/map/locations
POST   /api/map/locations
GET    /api/map/locations/:id
GET    /api/map/shifts

Changes:

  1. All endpoints prefixed with /api/
  2. RESTful conventions (GET/POST/PUT/DELETE instead of /create, /edit)
  3. Single port (4000) instead of two apps
  4. Namespaced by module (/influence/, /map/)

Request/Response Format

V1 Response (NocoDB-style):

{
  "list": [
    {
      "Id": 1,
      "Title": "Save the Trees",
      "Created": "2024-01-15T10:30:00Z"
    }
  ],
  "pageInfo": {
    "totalRows": 100,
    "page": 1,
    "pageSize": 20
  }
}

V2 Response (standardized):

{
  "success": true,
  "data": [
    {
      "id": "clx1a2b3c4d5e6f7g8h9i",
      "title": "Save the Trees",
      "createdAt": "2024-01-15T10:30:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 100,
    "totalPages": 5
  }
}

Changes:

  • V2 wraps responses in { success, data, pagination } structure
  • Field names: camelCase (createdAt) vs mixed case (Created)
  • IDs: CUID strings vs integers
  • Timestamps: ISO 8601 with milliseconds

Authentication Headers

V1 Requests:

// Session cookie sent automatically
fetch('/campaigns', {
  method: 'GET',
  credentials: 'include' // Sends session cookie
});

V2 Requests:

// JWT Bearer token required
fetch('/api/influence/campaigns', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
});

Impact: All API calls must be updated to include Authorization header. No more cookie-based authentication.

Validation Errors

V1 Validation (express-validator):

{
  "errors": [
    {
      "msg": "Invalid email",
      "param": "email",
      "location": "body"
    }
  ]
}

V2 Validation (Zod):

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "path": ["email"],
        "message": "Invalid email"
      }
    ]
  }
}

Database Schema Changes

Campaign Model

V1 NocoDB Table (campaigns):

Columns:
- Id (integer, auto-increment)
- Title (string)
- Description (text)
- Slug (string)
- IsActive (boolean)
- Created (datetime)

V2 Prisma Model:

model Campaign {
  id                   String    @id @default(cuid())
  title                String
  description          String?
  slug                 String    @unique
  active               Boolean   @default(true)
  highlighted          Boolean   @default(false)
  targetLevel          String?
  targetPosition       String?
  targetName           String?
  targetEmail          String?
  targetPostalCode     String?
  customSubject        String?
  customBody           String?
  responseWallEnabled  Boolean   @default(true)
  createdAt            DateTime  @default(now())
  updatedAt            DateTime  @updatedAt
  createdByUserId      String
  createdBy            User      @relation("CampaignCreator", fields: [createdByUserId], references: [id])

  emails               CampaignEmail[]
  responses            RepresentativeResponse[]
}

Changes:

  1. New fields: highlighted, targetLevel, responseWallEnabled, createdByUserId
  2. Relations: Foreign key to User (V1 had no user relation)
  3. Renamed: IsActiveactive, CreatedcreatedAt
  4. Type changes: Description text → String? (nullable)

Location Model

V1 NocoDB Table (locations):

Columns:
- Id (integer)
- Address (string)
- Latitude (float)
- Longitude (float)
- SupportLevel (string)
- Notes (text)

V2 Prisma Model:

model Location {
  id                 String              @id @default(cuid())
  address            String
  addressLine2       String?
  city               String?
  province           String?
  postalCode         String?
  country            String              @default("Canada")
  latitude           Float?
  longitude          Float?
  geocoded           Boolean             @default(false)
  geocodedAt         DateTime?
  geocodeProvider    String?
  geocodeQuality     String?
  supportLevel       SupportLevel        @default(UNKNOWN)
  notes              String?
  contactName        String?
  contactPhone       String?
  contactEmail       String?
  unitNumber         String?
  buildingName       String?
  buildingUse        String?
  federalDistrict    String?
  cutId              String?
  createdAt          DateTime            @default(now())
  updatedAt          DateTime            @updatedAt
  createdByUserId    String
  updatedByUserId    String?

  createdBy          User                @relation("LocationCreator", fields: [createdByUserId], references: [id])
  updatedBy          User?               @relation("LocationUpdater", fields: [updatedByUserId], references: [id])
  cut                Cut?                @relation(fields: [cutId], references: [id])
  addresses          Address[]
  history            LocationHistory[]
  canvassVisits      CanvassVisit[]
}

enum SupportLevel {
  STRONG_SUPPORT
  SUPPORT
  UNDECIDED
  OPPOSED
  STRONG_OPPOSED
  UNKNOWN
  NOT_HOME
  MOVED
  DECEASED
}

Changes:

  1. Structured address: V1 single Address → V2 address, city, province, postalCode
  2. Geocoding metadata: geocoded, geocodedAt, geocodeProvider, geocodeQuality
  3. Contact fields: contactName, contactPhone, contactEmail
  4. NAR fields: unitNumber, buildingName, buildingUse, federalDistrict
  5. Relations: cutId, createdByUserId, updatedByUserId
  6. SupportLevel: V1 string → V2 enum

Shift Model

V1 NocoDB Table (shifts):

Columns:
- Id (integer)
- Name (string)
- StartTime (datetime)
- EndTime (datetime)
- Location (string)
- Capacity (integer)

V2 Prisma Model:

model Shift {
  id                String         @id @default(cuid())
  name              String
  description       String?
  startTime         DateTime
  endTime           DateTime
  location          String?
  capacity          Int?
  requirements      String?
  cutId             String?
  createdAt         DateTime       @default(now())
  updatedAt         DateTime       @updatedAt

  cut               Cut?           @relation(fields: [cutId], references: [id])
  signups           ShiftSignup[]
}

model ShiftSignup {
  id                String         @id @default(cuid())
  shiftId           String
  userId            String
  status            SignupStatus   @default(CONFIRMED)
  notes             String?
  confirmedAt       DateTime?
  cancelledAt       DateTime?
  createdAt         DateTime       @default(now())

  shift             Shift          @relation(fields: [shiftId], references: [id], onDelete: Cascade)
  user              User           @relation(fields: [userId], references: [id])

  @@unique([shiftId, userId])
}

enum SignupStatus {
  PENDING
  CONFIRMED
  CANCELLED
  COMPLETED
  NO_SHOW
}

Changes:

  1. Separate signups: V1 embedded → V2 ShiftSignup relation table
  2. New fields: description, requirements, cutId
  3. Signup tracking: status, confirmedAt, cancelledAt
  4. Unique constraint: One signup per user per shift

Configuration Changes

Environment Variables

V1 Environment (.env):

# V1 used separate .env files per app

# influence/.env
PORT=3333
NOCODB_URL=http://nocodb:8080
NOCODB_API_TOKEN=xxxxx
SESSION_SECRET=xxxxx
REDIS_URL=redis://redis:6379
SMTP_HOST=smtp.example.com
SMTP_USER=user@example.com
SMTP_PASS=password

# map/.env
PORT=3000
NOCODB_URL=http://nocodb:8080
NOCODB_API_TOKEN=xxxxx
SESSION_SECRET=xxxxx  # Different secret!
REDIS_URL=redis://redis:6379

V2 Environment (.env):

# Single unified .env file

# Database
DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?schema=public
V2_POSTGRES_USER=changemaker
V2_POSTGRES_PASSWORD=strongpassword
V2_POSTGRES_DB=changemaker_v2

# Redis
REDIS_URL=redis://:password@redis:6379
REDIS_PASSWORD=redispassword

# JWT Authentication
JWT_ACCESS_SECRET=access_secret_32_chars_minimum
JWT_REFRESH_SECRET=refresh_secret_32_chars_minimum
ENCRYPTION_KEY=encryption_key_32_chars_different_from_jwt

# API
API_PORT=4000
MEDIA_API_PORT=4100
NODE_ENV=production

# Email (SMTP)
SMTP_HOST=smtp.protonmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your@protonmail.com
SMTP_PASS=yourapppassword
SMTP_FROM=noreply@cmlite.org
EMAIL_TEST_MODE=false

# BullMQ
BULLMQ_REDIS_URL=redis://:password@redis:6379

# Listmonk (optional)
LISTMONK_SYNC_ENABLED=true
LISTMONK_URL=http://listmonk:9001
LISTMONK_ADMIN_USER=api_user
LISTMONK_ADMIN_PASSWORD=api_token

# Feature Flags
ENABLE_MEDIA_FEATURES=true

Removed V1 Variables:

  • NOCODB_URL (no longer using NocoDB as data layer)
  • NOCODB_API_TOKEN
  • SESSION_SECRET (replaced by JWT secrets)

New V2 Variables:

  • DATABASE_URL (direct PostgreSQL connection)
  • JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
  • ENCRYPTION_KEY (for encrypting sensitive DB fields)
  • LISTMONK_SYNC_ENABLED (newsletter integration)
  • ENABLE_MEDIA_FEATURES (media library toggle)

Docker Compose Changes

V1 Services:

services:
  influence-app:
    build: ./influence
    ports:
      - "3333:3333"

  map-app:
    build: ./map
    ports:
      - "3000:3000"

  nocodb:
    image: nocodb/nocodb:latest
    ports:
      - "8080:8080"

  redis:
    image: redis:alpine

V2 Services:

services:
  api:
    build: ./api
    ports:
      - "4000:4000"
    depends_on:
      - v2-postgres
      - redis

  media-api:
    build:
      context: ./api
      dockerfile: Dockerfile.media
    ports:
      - "4100:4100"

  admin:
    build: ./admin
    ports:
      - "3000:3000"

  v2-postgres:
    image: postgres:16-alpine
    ports:
      - "5433:5432"

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"

Changes:

  1. Removed: influence-app, map-app, nocodb
  2. Added: api, media-api, admin, v2-postgres, nginx
  3. Port changes: API 3333/3000 → 4000, Admin GUI on 3000
  4. Redis: Now requires authentication (--requirepass)

Code Migration Examples

Campaign List Endpoint

V1 Implementation:

// influence/routes/campaigns.js
router.get('/campaigns', async (req, res) => {
  try {
    const response = await axios.get(
      `${process.env.NOCODB_URL}/api/v1/db/data/v1/campaigns`,
      {
        headers: { 'xc-token': process.env.NOCODB_API_TOKEN },
        params: {
          where: '(IsActive,eq,true)',
          sort: '-Created',
          limit: 20,
          offset: req.query.page ? (req.query.page - 1) * 20 : 0
        }
      }
    );

    res.render('campaigns', {
      campaigns: response.data.list,
      pageInfo: response.data.pageInfo
    });
  } catch (error) {
    console.error(error);
    res.status(500).send('Error fetching campaigns');
  }
});

V2 Implementation:

// api/src/modules/influence/campaigns/campaigns.routes.ts
router.get('/', authenticate, async (req: Request, res: Response) => {
  const query = listCampaignsSchema.parse(req.query);
  const result = await campaignService.list(query);
  res.json({ success: true, ...result });
});

// api/src/modules/influence/campaigns/campaigns.service.ts
async list(params: ListCampaignsParams) {
  const { page = 1, limit = 20, search, active, highlighted } = params;

  const where: Prisma.CampaignWhereInput = {
    ...(active !== undefined && { active }),
    ...(highlighted !== undefined && { highlighted }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' } },
        { description: { contains: search, mode: 'insensitive' } }
      ]
    })
  };

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

  return {
    data: campaigns,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  };
}

Changes:

  1. V1: HTTP request to NocoDB → V2: Prisma ORM query
  2. V1: Query string filtering → V2: Zod schema validation
  3. V1: EJS rendering → V2: JSON API response
  4. V1: Manual pagination → V2: Standardized pagination object
  5. V2: Type safety (TypeScript), includes relations

User Login

V1 Implementation:

// influence/routes/auth.js
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const response = await axios.get(
    `${process.env.NOCODB_URL}/api/v1/db/data/v1/influence_users`,
    {
      headers: { 'xc-token': process.env.NOCODB_API_TOKEN },
      params: { where: `(Email,eq,${email})` }
    }
  );

  if (response.data.list.length === 0) {
    return res.status(401).send('Invalid credentials');
  }

  const user = response.data.list[0];
  const validPassword = await bcrypt.compare(password, user.Password);

  if (!validPassword) {
    return res.status(401).send('Invalid credentials');
  }

  req.session.userId = user.Id;
  req.session.role = user.Role;

  res.redirect('/dashboard');
});

V2 Implementation:

// api/src/modules/auth/auth.service.ts
async login(email: string, password: string) {
  const user = await prisma.user.findUnique({ where: { email } });

  if (!user) {
    // Prevent user enumeration - same error for wrong email or password
    throw new UnauthorizedError('Invalid credentials');
  }

  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) {
    throw new UnauthorizedError('Invalid credentials');
  }

  if (user.status !== 'ACTIVE') {
    throw new UnauthorizedError('Account is not active');
  }

  // Generate JWT tokens
  const accessToken = jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { id: user.id },
    env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  // Store refresh token (with rotation on use)
  await prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  });

  // Update last login
  await prisma.user.update({
    where: { id: user.id },
    data: { lastLoginAt: new Date() }
  });

  return {
    user: { id: user.id, email: user.email, name: user.name, role: user.role },
    accessToken,
    refreshToken
  };
}

Changes:

  1. V1: Session storage → V2: JWT tokens returned to client
  2. V1: Redirect to dashboard → V2: JSON response with tokens
  3. V2: User enumeration prevention (same error message)
  4. V2: Account status check (ACTIVE, SUSPENDED, etc.)
  5. V2: Refresh token storage for rotation
  6. V2: Last login tracking

Deployment Changes

Port Mapping

Service V1 Port V2 Port Notes
Influence App 3333 - Removed
Map App 3000 - Removed
Admin GUI - 3000 New React app
Express API - 4000 New unified API
Fastify Media API - 4100 New media service
NocoDB 8080 8091 Now read-only browser
PostgreSQL (main) - 5433 New V2 database
Listmonk - 9001 New newsletter service
Grafana - 3001 New monitoring
Prometheus - 9090 New metrics

Nginx Routing

V1 Nginx (simple proxy):

server {
  listen 80;
  server_name cmlite.org;

  location /influence {
    proxy_pass http://influence-app:3333;
  }

  location /map {
    proxy_pass http://map-app:3000;
  }
}

V2 Nginx (subdomain routing):

# Admin GUI
server {
  listen 80;
  server_name app.cmlite.org;
  location / {
    proxy_pass http://admin:3000;
  }
}

# Main API
server {
  listen 80;
  server_name api.cmlite.org;
  location / {
    proxy_pass http://api:4000;
  }
}

# Media API
server {
  listen 80;
  server_name media.cmlite.org;
  location / {
    proxy_pass http://media-api:4100;
  }
}

# Public site (MkDocs)
server {
  listen 80;
  server_name cmlite.org;
  location / {
    proxy_pass http://mkdocs:4001;
  }
}

Impact: V2 requires DNS configuration for subdomains (app., api., media., etc.).

Feature Changes

Features Removed in V2

  1. NocoDB Data Browser (as primary interface)

    • V2 uses NocoDB only as read-only browser
    • All CRUD operations via API/Admin GUI
  2. Embedded EJS Views

    • No server-rendered templates
    • All UI is React SPA
  3. Session-Based Multi-Tenancy

    • V1 supported multiple campaigns with session isolation
    • V2 is single-tenant (one installation per organization)

Features Added in V2

  1. Landing Page Builder

    • GrapesJS visual editor
    • Custom blocks library
    • MkDocs export (Jinja2 templates)
  2. Email Templates System

    • Template versioning
    • Variable substitution
    • Live preview
    • HTML + plain text variants
  3. Media Library

    • Video upload with FFprobe metadata
    • Public gallery with categories
    • Reaction system (6 emoji types)
    • Bulk operations
  4. Volunteer Canvassing

    • GPS tracking sessions
    • Walking route algorithm
    • Visit outcome recording
    • Admin dashboard with leaderboards
  5. Data Quality Dashboard

    • Geocoding quality metrics
    • Provider performance comparison
    • Bulk re-geocoding tools
  6. Comprehensive Monitoring

    • Prometheus metrics (12 custom cm_* metrics)
    • Grafana dashboards (3 pre-configured)
    • Alertmanager with Gotify integration
    • Docker healthchecks
  7. NAR 2025 Import

    • Canadian electoral data import
    • Server-side streaming (large files)
    • Location + Address file joining
    • Province/city/postal filtering
  8. Pangolin Tunnel

    • Self-hosted tunnel alternative to Cloudflare
    • Newt container integration
    • Admin setup wizard

Features Changed in V2

  1. Campaign Email Sending

    • V1: Bull job queue → V2: BullMQ with monitoring
    • V1: Single SMTP config → V2: Test mode + Listmonk integration
  2. Response Wall

    • V1: Simple submission form → V2: Moderation + upvoting + verification
  3. Geocoding

    • V1: Single provider (Nominatim) → V2: 6 providers with fallback
    • V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage
  4. User Roles

    • V1: admin, user → V2: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
    • V2: Role-based access control (RBAC) middleware

Security Changes

Enhancements in V2

  1. Password Policy

    • V1: No requirements → V2: 12+ chars, uppercase, lowercase, digit (Zod schema)
  2. Rate Limiting

    • V1: None → V2: Auth endpoints 10/min per IP, canvass visits 30/min
  3. Refresh Token Rotation

    • V1: Static sessions → V2: Atomic token rotation (prevents replay attacks)
  4. User Enumeration Prevention

    • V2: Login returns 401 for both invalid email and password (V1 returned different errors)
  5. Redis Authentication

    • V1: No password → V2: Required REDIS_PASSWORD
  6. Encryption Key

    • V2: Separate ENCRYPTION_KEY for sensitive DB fields (different from JWT secrets)
  7. Input Sanitization

    • V2: HTML escaping for user content (responses, emails, templates)
  8. Path Traversal Protection

    • V2: Null byte checks, path normalization, encoded traversal blocking

Security Audit

V2 underwent comprehensive security audit (2025-02-11) addressing 13 findings:

  • 1 Critical, 6 Important, 3 Medium, 2 Low, 1 Suggestion

See Security Audit Report for details.

Performance Considerations

V1 Performance Characteristics

  • Database Access: HTTP requests to NocoDB (REST API overhead)
  • N+1 Queries: Common due to REST API pagination
  • Caching: Redis sessions only
  • Concurrency: Limited by Node.js single-threaded event loop

V2 Performance Improvements

  1. Direct Database Access

    • Prisma ORM eliminates REST API overhead
    • Connection pooling reduces latency
  2. Query Optimization

    • Prisma includes relations in single query (no N+1)
    • Indexed foreign keys, unique constraints
  3. Caching Strategy

    • Redis cache for representatives (60min TTL)
    • Redis cache for postal codes (persistent)
    • Prisma query result caching
  4. Dual API Architecture

    • Media API (Fastify) handles video uploads separately
    • Prevents main API blocking on large file uploads
  5. Monitoring

    • Prometheus http_request_duration_seconds histogram
    • Slow query detection via metrics
    • Grafana alerting on high latency

Next Steps

  1. Review this breaking changes document thoroughly
  2. Plan data transformation scripts (user merging, ID mapping)
  3. Test authentication migration (password hashes, login flow)
  4. Set up V2 staging environment for testing
  5. Proceed to Data Migration Guide

!!! warning "Migration Complexity" V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.