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

1168 lines
29 KiB
Markdown

# 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):
```javascript
// 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):
```typescript
// 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**:
```javascript
// 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**:
```typescript
// 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.
```javascript
// 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):
```json
{
"id": 1,
"email": "admin@example.com",
"password": "$2b$10...",
"role": "admin"
}
```
**Login Users** (`login` table):
```json
{
"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)
```prisma
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):
```javascript
// 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):
```html
<!-- influence/views/campaigns.ejs -->
<% campaigns.forEach(campaign => { %>
<div class="card">
<h3><%= campaign.title %></h3>
<p><%= campaign.description %></p>
</div>
<% }) %>
```
**V2 Component** (React + TypeScript):
```tsx
// 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):
```json
{
"list": [
{
"Id": 1,
"Title": "Save the Trees",
"Created": "2024-01-15T10:30:00Z"
}
],
"pageInfo": {
"totalRows": 100,
"page": 1,
"pageSize": 20
}
}
```
**V2 Response** (standardized):
```json
{
"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**:
```javascript
// Session cookie sent automatically
fetch('/campaigns', {
method: 'GET',
credentials: 'include' // Sends session cookie
});
```
**V2 Requests**:
```typescript
// 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):
```json
{
"errors": [
{
"msg": "Invalid email",
"param": "email",
"location": "body"
}
]
}
```
**V2 Validation** (Zod):
```json
{
"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**:
```prisma
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**: `IsActive``active`, `Created``createdAt`
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**:
```prisma
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**:
```prisma
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`):
```bash
# 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`):
```bash
# 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**:
```yaml
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**:
```yaml
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**:
```javascript
// 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**:
```typescript
// 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**:
```javascript
// 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**:
```typescript
// 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):
```nginx
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):
```nginx
# 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](../SECURITY_AUDIT_2025-02-11.md) 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
## Related Documentation
- [Data Migration Procedures](data-migration.md) - Step-by-step migration guide
- [API Endpoint Changes](api-changes.md) - Complete endpoint mapping
- [Feature Parity Matrix](feature-parity.md) - Feature comparison
- [V2 Architecture](../architecture/index.md) - System design
- [V2 Database Schema](../database/schema.md) - Prisma models
## 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](data-migration.md)
!!! warning "Migration Complexity"
V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.