1168 lines
29 KiB
Markdown
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.
|