988 lines
25 KiB
Markdown

# Representatives Module
## Overview
The Representatives module integrates with the Canadian [Represent API](https://represent.opennorth.ca/) to provide elected official lookup by postal code. It features intelligent caching, rate limiting, deduplication, and both public and admin endpoints for managing representative data.
**Key Features:**
- Canadian representative lookup via Represent API (MPs, MPPs, councillors)
- Intelligent cache-first strategy with fire-and-forget cache writes
- Rate limiting (55 requests/minute, under Represent API's 60/min limit)
- Representative deduplication (centroid + concordance results)
- Public postal code lookup (no auth required)
- Admin cache management (view, clear, stats)
- Integration with postal codes module for location metadata
- Health check endpoint for API connectivity testing
## File Paths
| File | Purpose |
|------|---------|
| `api/src/modules/influence/representatives/representatives.routes.ts` | Router with 8 endpoints (2 public, 6 admin) |
| `api/src/modules/influence/representatives/representatives.service.ts` | Representative business logic + Represent API integration |
| `api/src/modules/influence/representatives/representatives.schemas.ts` | Zod validation schemas |
| `api/src/modules/influence/representatives/represent-api.client.ts` | Represent API HTTP client with rate limiting |
## Database Model
```prisma
model Representative {
id String @id @default(cuid())
postalCode String
name String?
email String?
districtName String?
electedOffice String?
partyName String?
representativeSetName String?
url String?
photoUrl String?
offices Json? // JSON array of office contact info
cachedAt DateTime @default(now())
@@index([postalCode])
@@map("representatives")
}
```
**Field Descriptions:**
- `postalCode` — Canadian postal code (e.g., "M5H 2N2")
- `name` — Representative's full name
- `email` — Contact email address
- `districtName` — Electoral district name (e.g., "Toronto Centre")
- `electedOffice` — Position (e.g., "MP", "MPP", "Councillor")
- `partyName` — Political party affiliation
- `representativeSetName` — Data source identifier (e.g., "House of Commons")
- `url` — Representative's official website
- `photoUrl` — Profile photo URL
- `offices` — JSON array of office locations with contact info
- `cachedAt` — Timestamp when cached from Represent API
## API Endpoints
### Public Endpoints (No Authentication)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/representatives/by-postal/:postalCode` | Lookup representatives by postal code (cache-first) |
| GET | `/api/representatives/test-connection` | Test Represent API connectivity |
### Admin Endpoints (Authentication Required)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/representatives/cache-stats` | Admin roles | Get cache statistics |
| GET | `/api/representatives` | Admin roles | List all cached representatives (paginated) |
| GET | `/api/representatives/:id` | Admin roles | Get single cached representative |
| DELETE | `/api/representatives/by-postal/:postalCode` | Admin roles | Clear cache for postal code |
| DELETE | `/api/representatives/:id` | Admin roles | Delete single cached representative |
**Admin Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`
---
## Public Endpoint Details
### GET /api/representatives/by-postal/:postalCode
Lookup representatives by Canadian postal code. Uses cache-first strategy: returns cached results if available, otherwise calls Represent API and caches results asynchronously.
**Path Parameters:**
- `postalCode` (string): Canadian postal code (e.g., "M5H2N2" or "M5H 2N2")
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| refresh | boolean | No | false | Force API call even if cached data exists |
**Example Request:**
```bash
# Cache-first lookup
curl "http://api.cmlite.org/api/representatives/by-postal/M5H2N2"
# Force refresh from API
curl "http://api.cmlite.org/api/representatives/by-postal/M5H2N2?refresh=true"
```
**Response (200 OK):**
```json
{
"source": "cache",
"postalCode": "M5H2N2",
"location": {
"city": "Toronto",
"province": "ON"
},
"representatives": [
{
"id": "clx1234567890",
"postalCode": "M5H2N2",
"name": "Chrystia Freeland",
"email": "chrystia.freeland@parl.gc.ca",
"districtName": "University—Rosedale",
"electedOffice": "MP",
"partyName": "Liberal",
"representativeSetName": "House of Commons",
"url": "https://www.ourcommons.ca/members/en/chrystia-freeland(71619)",
"photoUrl": "https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg",
"offices": [
{
"type": "constituency",
"tel": "416-656-2424",
"fax": "416-656-2425",
"postal": "703-2005 Sheppard Ave E, Toronto ON M2J 5B4"
}
],
"cachedAt": "2026-02-11T12:00:00.000Z"
},
{
"id": "clx0987654321",
"postalCode": "M5H2N2",
"name": "Suze Morrison",
"email": "smorrisons@ola.org",
"districtName": "Toronto Centre",
"electedOffice": "MPP",
"partyName": "NDP",
"representativeSetName": "Legislative Assembly of Ontario",
"url": "https://www.ola.org/en/members/all/suze-morrison",
"photoUrl": null,
"offices": [],
"cachedAt": "2026-02-11T12:00:00.000Z"
}
]
}
```
**Response Fields:**
- `source` — Data source: `"cache"` (from database) or `"api"` (fresh from Represent API)
- `postalCode` — Normalized postal code
- `location` — City and province from PostalCodeCache table
- `representatives` — Array of representative objects
**Error Responses:**
- `400 Bad Request`: Invalid postal code format
- `404 Not Found`: Postal code not found in Represent API
- `429 Too Many Requests`: Rate limit exceeded (55/min)
- `500 Internal Server Error`: Represent API unreachable or other error
**Caching Strategy:**
```typescript
// 1. Check cache first (unless forceRefresh)
const cached = await prisma.representative.findMany({ where: { postalCode: code } });
if (cached.length > 0 && !forceRefresh) {
return { source: 'cache', representatives: cached };
}
// 2. Call Represent API
const apiResponse = await representApiClient.getByPostalCode(code);
// 3. Fire-and-forget cache write (don't await)
cacheWrite(); // Deletes old cache, creates new entries
// 4. Return API results immediately (don't wait for cache)
return { source: 'api', representatives: uniqueReps };
```
**Deduplication:**
Representatives from both `representatives_centroid` and `representatives_concordance` are merged and deduplicated by `name|elected_office` key to avoid duplicate entries.
```typescript
function deduplicateReps(reps: RepresentRepresentative[]): RepresentRepresentative[] {
const seen = new Set<string>();
return reps.filter((rep) => {
const key = `${rep.name}|${rep.elected_office}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
```
---
### GET /api/representatives/test-connection
Test connectivity to the Represent API.
**Example Request:**
```bash
curl "http://api.cmlite.org/api/representatives/test-connection"
```
**Response (200 OK):**
```json
{
"ok": true,
"message": "Represent API is reachable"
}
```
**Response (200 OK, API Down):**
```json
{
"ok": false,
"message": "HTTP 503"
}
```
**Use Cases:**
- Health checks for monitoring dashboards
- Troubleshooting representative lookup issues
- Verifying API configuration in admin settings
---
## Admin Endpoint Details
### GET /api/representatives/cache-stats
Get cache statistics for the representatives cache.
**Authentication:** Required (Admin roles)
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/representatives/cache-stats"
```
**Response (200 OK):**
```json
{
"totalRepresentatives": 1247,
"postalCodesWithRepresentatives": 412,
"totalPostalCodes": 450
}
```
**Field Descriptions:**
- `totalRepresentatives` — Total cached representative records
- `postalCodesWithRepresentatives` — Unique postal codes with cached representatives
- `totalPostalCodes` — Total postal codes in PostalCodeCache table (includes codes without representatives)
---
### GET /api/representatives
List all cached representatives with pagination and search.
**Authentication:** Required (Admin roles)
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | number | No | 1 | Page number |
| limit | number | No | 20 | Results per page (max 100) |
| search | string | No | - | Search name, email, district, or office |
| postalCode | string | No | - | Filter by postal code |
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/representatives?page=1&limit=10&search=Toronto&postalCode=M5H2N2"
```
**Response (200 OK):**
```json
{
"representatives": [
{
"id": "clx1234567890",
"postalCode": "M5H2N2",
"name": "Chrystia Freeland",
"email": "chrystia.freeland@parl.gc.ca",
"districtName": "University—Rosedale",
"electedOffice": "MP",
"partyName": "Liberal",
"representativeSetName": "House of Commons",
"url": "https://www.ourcommons.ca/members/en/chrystia-freeland(71619)",
"photoUrl": "https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg",
"offices": [...],
"cachedAt": "2026-02-11T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 15,
"totalPages": 2
}
}
```
**Search Logic:**
Search term is matched against name, email, district name, or elected office (case-insensitive):
```typescript
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ districtName: { contains: search, mode: 'insensitive' } },
{ electedOffice: { contains: search, mode: 'insensitive' } },
];
}
```
---
### GET /api/representatives/:id
Get single cached representative by ID.
**Authentication:** Required (Admin roles)
**Path Parameters:**
- `id` (string): Representative ID (cuid)
**Example Request:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/representatives/clx1234567890"
```
**Response (200 OK):**
Returns single representative object (same format as list).
**Error Responses:**
- `404 Not Found`: Representative not found
---
### DELETE /api/representatives/by-postal/:postalCode
Clear all cached representatives for a specific postal code.
**Authentication:** Required (Admin roles)
**Path Parameters:**
- `postalCode` (string): Canadian postal code
**Example Request:**
```bash
curl -X DELETE -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/representatives/by-postal/M5H2N2"
```
**Response (200 OK):**
```json
{
"deleted": 3,
"postalCode": "M5H2N2"
}
```
**Use Cases:**
- Force cache refresh for specific postal code
- Remove stale data after election
- Troubleshoot incorrect representative data
---
### DELETE /api/representatives/:id
Delete single cached representative by ID.
**Authentication:** Required (Admin roles)
**Path Parameters:**
- `id` (string): Representative ID (cuid)
**Example Request:**
```bash
curl -X DELETE -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/representatives/clx1234567890"
```
**Response (204 No Content):**
No response body.
**Error Responses:**
- `404 Not Found`: Representative not found
---
## Represent API Integration
### API Client
The `represent-api.client.ts` file provides a typed HTTP client for the [Represent API](https://represent.opennorth.ca/).
**Base URL:**
```typescript
const REPRESENT_API_URL = 'https://represent.opennorth.ca';
```
**Configuration:**
Set `REPRESENT_API_URL` in `.env` to override (default: `https://represent.opennorth.ca`).
**Methods:**
```typescript
class RepresentApiClient {
// Lookup by postal code
async getByPostalCode(code: string): Promise<RepresentPostalCodeResponse>;
// Health check
async testConnection(): Promise<{ ok: boolean; message: string }>;
}
```
### Rate Limiting
**Limits:**
- Represent API: 60 requests/minute
- Changemaker Lite: 55 requests/minute (safety margin)
**Implementation:**
In-memory sliding window rate limiter:
```typescript
const RATE_LIMIT = 55;
const RATE_WINDOW_MS = 60_000;
const requestTimestamps: number[] = [];
function checkRateLimit(): boolean {
const now = Date.now();
// Remove timestamps outside the window
while (requestTimestamps.length > 0 && requestTimestamps[0] < now - RATE_WINDOW_MS) {
requestTimestamps.shift();
}
return requestTimestamps.length < RATE_LIMIT;
}
function recordRequest(): void {
requestTimestamps.push(Date.now());
}
```
**Behavior:**
- If rate limit exceeded: throws `Error('Represent API rate limit reached. Please try again in a minute.')`
- Returns 429 status to client
- Resets after 1 minute
### Response Schema
```typescript
interface RepresentPostalCodeResponse {
city: string | null;
province: string | null;
centroid: { type: string; coordinates: [number, number] } | null;
representatives_centroid: RepresentRepresentative[];
representatives_concordance: RepresentRepresentative[];
}
interface RepresentRepresentative {
name: string;
email: string | null;
elected_office: string;
district_name: string;
party_name: string | null;
representative_set_name: string;
url: string;
photo_url: string | null;
offices: RepresentOffice[];
}
interface RepresentOffice {
type?: string; // "constituency" or "legislature"
tel?: string; // Phone number
fax?: string; // Fax number
postal?: string; // Mailing address
}
```
**Centroid vs. Concordance:**
- `representatives_centroid` — Representatives found using the postal code's geographic centroid
- `representatives_concordance` — Representatives found using postal code concordance tables (may be more accurate for boundary-edge postal codes)
- Both arrays are merged and deduplicated by Changemaker Lite
---
## Service Functions
### representativesService.lookupByPostalCode(code, forceRefresh)
Cache-first representative lookup.
**Parameters:**
- `code` (string): Canadian postal code
- `forceRefresh` (boolean, default: false): Skip cache and force API call
**Returns:**
```typescript
{
source: 'cache' | 'api';
postalCode: string;
location: { city: string | null; province: string | null };
representatives: Representative[];
}
```
**Logic Flow:**
1. Check cache unless `forceRefresh=true`
2. If cached data found, return immediately with `source: 'cache'`
3. If no cache or `forceRefresh`, call Represent API
4. Merge centroid + concordance representatives and deduplicate
5. Fire-and-forget cache write (delete old, insert new, upsert postal code)
6. Return API results with `source: 'api'` (don't wait for cache)
**Fire-and-Forget Caching:**
```typescript
const cacheWrite = async () => {
try {
// Delete old cached reps for this postal code
await prisma.representative.deleteMany({ where: { postalCode: code } });
// Cache new reps
await prisma.representative.createMany({
data: uniqueReps.map((rep) => ({
postalCode: code,
name: rep.name || null,
email: rep.email || null,
districtName: rep.district_name || null,
electedOffice: rep.elected_office || null,
partyName: rep.party_name || null,
representativeSetName: rep.representative_set_name || null,
url: rep.url || null,
photoUrl: rep.photo_url || null,
offices: rep.offices ? (rep.offices as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,
})),
});
// Upsert postal code cache (city, province, centroid)
await postalCodesService.upsert({
postalCode: code,
city: apiResponse.city,
province: apiResponse.province,
centroidLat: coords ? coords[1] : null,
centroidLng: coords ? coords[0] : null,
});
} catch (err) {
logger.error('Failed to cache representatives', { postalCode: code, error: err });
}
};
// Don't await — fire and forget
cacheWrite();
```
**Why Fire-and-Forget?**
- Returns API results to user immediately (faster response)
- Cache failures don't block user requests
- Next lookup will use cached data if write succeeds
- Errors logged for monitoring but don't propagate to user
---
### representativesService.findAll(filters)
List cached representatives with pagination and search.
**Parameters:**
```typescript
{
page: number; // Page number (default: 1)
limit: number; // Results per page (max 100, default: 20)
search?: string; // Search term (optional)
postalCode?: string; // Filter by postal code (optional)
}
```
**Returns:**
```typescript
{
representatives: Representative[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
```
---
### representativesService.findById(id)
Get single cached representative by ID.
**Throws:** `AppError(404)` if not found
---
### representativesService.clearByPostalCode(code)
Delete all cached representatives for a postal code.
**Returns:**
```typescript
{
deleted: number; // Count of deleted records
postalCode: string;
}
```
---
### representativesService.deleteById(id)
Delete single cached representative by ID.
**Throws:** `AppError(404)` if not found
---
### representativesService.testApiConnection()
Test connectivity to Represent API.
**Returns:**
```typescript
{
ok: boolean;
message: string;
}
```
**Implementation:**
Calls Represent API's `/boundary-sets/?limit=1` endpoint (lightweight health check).
---
### representativesService.getCacheStats()
Get cache statistics.
**Returns:**
```typescript
{
totalRepresentatives: number; // Total cached representative records
postalCodesWithRepresentatives: number; // Unique postal codes with reps
totalPostalCodes: number; // Total postal codes in cache
}
```
**Implementation:**
```typescript
const [totalReps, postalCodesWithReps, totalPostalCodes] = await Promise.all([
prisma.representative.count(),
prisma.representative.groupBy({ by: ['postalCode'] }).then((g) => g.length),
prisma.postalCodeCache.count(),
]);
return {
totalRepresentatives: totalReps,
postalCodesWithRepresentatives: postalCodesWithReps,
totalPostalCodes,
};
```
---
## Validation Schemas
### List Representatives Schema
```typescript
export const listRepresentativesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
postalCode: z.string().optional(),
});
export type ListRepresentativesInput = z.infer<typeof listRepresentativesSchema>;
```
**Coercion:**
- `page` and `limit` coerced from query string to number
- Invalid values fallback to defaults
---
## Integration with Postal Codes Module
The representatives module integrates with the postal codes module (`api/src/modules/influence/postal-codes/`) for location metadata.
**PostalCodeCache Model:**
```prisma
model PostalCodeCache {
id String @id @default(cuid())
postalCode String @unique
city String?
province String?
centroidLat Float?
centroidLng Float?
cachedAt DateTime @default(now())
}
```
**Integration Points:**
1. **Lookup:** When returning cached representatives, fetch city/province from `PostalCodeCache`:
```typescript
const postalInfo = await postalCodesService.findByPostalCode(code);
return {
source: 'cache',
location: {
city: postalInfo?.city ?? null,
province: postalInfo?.province ?? null,
},
representatives: cached,
};
```
2. **Cache Write:** After calling Represent API, upsert postal code with location data:
```typescript
await postalCodesService.upsert({
postalCode: code,
city: apiResponse.city,
province: apiResponse.province,
centroidLat: coords ? coords[1] : null,
centroidLng: coords ? coords[0] : null,
});
```
---
## Code Examples
### Public: Lookup Representatives by Postal Code
```typescript
import axios from 'axios';
const lookupRepresentatives = async (postalCode: string) => {
const { data } = await axios.get(
`/api/representatives/by-postal/${postalCode}`
);
console.log(`Source: ${data.source}`); // "cache" or "api"
console.log(`Location: ${data.location.city}, ${data.location.province}`);
data.representatives.forEach((rep) => {
console.log(`${rep.name} (${rep.electedOffice}) - ${rep.email}`);
});
return data;
};
// Cache-first lookup
await lookupRepresentatives('M5H2N2');
// Force refresh from API
const { data } = await axios.get('/api/representatives/by-postal/M5H2N2?refresh=true');
```
### Admin: Get Cache Statistics
```typescript
import { api } from '@/lib/api';
const getCacheStats = async () => {
const { data } = await api.get('/api/representatives/cache-stats');
console.log(`Total Representatives: ${data.totalRepresentatives}`);
console.log(`Postal Codes with Reps: ${data.postalCodesWithRepresentatives}`);
console.log(`Total Postal Codes: ${data.totalPostalCodes}`);
return data;
};
```
### Admin: Clear Cache for Postal Code
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const clearPostalCodeCache = async (postalCode: string) => {
try {
const { data } = await api.delete(`/api/representatives/by-postal/${postalCode}`);
message.success(`Cleared ${data.deleted} representatives for ${postalCode}`);
} catch (error) {
message.error('Failed to clear cache');
}
};
```
### Admin: Search Cached Representatives
```typescript
import { api } from '@/lib/api';
const searchRepresentatives = async (search: string, page: number = 1) => {
const { data } = await api.get('/api/representatives', {
params: { search, page, limit: 20 },
});
return {
representatives: data.representatives,
pagination: data.pagination,
};
};
```
---
## Frontend Integration
The RepresentativesPage component (`admin/src/pages/RepresentativesPage.tsx`) provides:
- Cache statistics dashboard (total reps, postal codes, coverage)
- Representative cache table with pagination
- Search by name, email, district, or office
- Filter by postal code
- Clear cache by postal code (bulk action)
- Delete individual cached representatives
- Postal code lookup tool (test Represent API)
- Connection test (verify API reachability)
- Refresh button (force API call for postal code)
**State Management:**
```typescript
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({ search: '', postalCode: '' });
const [stats, setStats] = useState({ totalRepresentatives: 0, postalCodesWithRepresentatives: 0, totalPostalCodes: 0 });
```
---
## Performance Considerations
**Cache-First Strategy:**
- Cached lookups: <10ms (database query)
- API lookups: 200-500ms (external API call)
- Fire-and-forget writes don't block user response
**Rate Limiting:**
- 55 requests/minute limit prevents Represent API 429 errors
- In-memory sliding window (no Redis overhead)
- Returns 429 status to client when limit exceeded
**Database Indexing:**
- `@@index([postalCode])` Fast lookup by postal code
- Ordered by `cachedAt DESC` Recent lookups first
**Deduplication:**
- Prevents duplicate representatives from centroid + concordance results
- Reduces database storage and frontend rendering load
---
## Troubleshooting
### Issue: "Represent API rate limit reached"
**Cause:** More than 55 requests in 60-second window
**Solution:**
- Wait 1 minute and retry
- Use cached data (don't force refresh)
- Batch postal code lookups instead of sequential
### Issue: Cached data is stale
**Cause:** Representative changed after election
**Solution:**
- Force refresh: `GET /api/representatives/by-postal/:postalCode?refresh=true`
- Admin clear cache: `DELETE /api/representatives/by-postal/:postalCode`
- Cache will be refreshed on next lookup
### Issue: Postal code returns no representatives
**Cause:** Invalid postal code or Represent API doesn't have data
**Solution:**
- Verify postal code format (e.g., "M5H2N2" or "M5H 2N2")
- Check Represent API directly: https://represent.opennorth.ca/postcodes/M5H2N2/
- Ensure postal code is Canadian (Represent API is Canada-only)
### Issue: Duplicate representatives in cache
**Cause:** Deduplication bug or manual database insertion
**Solution:**
- Clear cache: `DELETE /api/representatives/by-postal/:postalCode`
- Next lookup will re-deduplicate from API
---
## Related Documentation
- [Postal Codes Module](/v2/backend/modules/postal-codes.md) - Postal code cache integration
- [Campaigns Module](/v2/backend/modules/campaigns.md) - Campaign email sending to representatives
- [Frontend: RepresentativesPage](/v2/frontend/pages/admin/representatives-page.md) - Cache management UI
- [Frontend: Public Campaign Page](/v2/frontend/pages/public/campaign-page.md) - Public representative lookup
- [API Reference: Representatives](/v2/api-reference/representatives.md) - Complete endpoint reference
- [Feature: Influence System](/v2/features/influence/representative-lookup.md) - Representative lookup feature guide
- [Represent API Documentation](https://represent.opennorth.ca/api/) - Official Represent API docs