28 KiB

Canvassing Session System

Overview

The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.

Key Capabilities:

  • Session Lifecycle: ACTIVE → COMPLETED → ABANDONED (auto-close after 12h)
  • Walking Route Algorithm: Nearest-neighbor optimization from volunteer GPS position
  • Visit Recording: 7 outcome types with support level updates
  • GPS Integration: Live tracking via TrackingSession (1:1 relationship)
  • Rate Limiting: 30 visits/min per IP to prevent abuse
  • Progress Tracking: Cut completion percentage auto-calculated
  • Admin Oversight: Active sessions dashboard, activity feed, leaderboard
  • Volunteer Portal: Full-screen map with bottom-sheet visit recording

Use Cases:

  • Door-to-door canvassing for electoral campaigns
  • Voter ID (identifying supporter levels)
  • GOTV (Get Out The Vote) efforts
  • Sign placement tracking
  • Petition signature collection
  • Issue surveys
  • Volunteer coordination

Architecture

graph TD
    A[Volunteer] -->|Start Session| B[VolunteerMapPage]
    B -->|POST /api/map/canvass/sessions| C[Canvass Service]
    C -->|Create| D[(CanvassSession)]
    C -->|Start GPS| E[Tracking Service]
    E -->|Create| F[(TrackingSession)]

    B -->|Load Addresses| C
    C -->|Filter by Cut| G[Spatial Utils]
    G -->|Point-in-Polygon| H[(Location Model)]

    B -->|Calculate Route| I[Walking Route Service]
    I -->|Nearest Neighbor| J[Haversine Distance]
    J -->|Return Route| B

    K[Volunteer GPS] -->|Submit Points| E
    E -->|Save| L[(TrackPoint)]
    E -->|Calculate Distance| J

    B -->|Record Visit| C
    C -->|Create| M[(CanvassVisit)]
    C -->|Update Address| N[(Address Model)]
    C -->|Update Progress| D

    O[Admin] -->|View Dashboard| P[CanvassDashboardPage]
    P -->|GET /api/map/canvass/admin/activity| C
    C -->|Aggregate Stats| D
    C -->|Activity Feed| M

    D -->|1:1| F
    D -->|1:N| M
    M -->|N:1| N

    style D fill:#e1f5ff
    style F fill:#e1f5ff
    style M fill:#e1f5ff
    style N fill:#e1f5ff
    style H fill:#e1f5ff

Flow Description:

  1. Volunteer starts session → Creates CanvassSession + TrackingSession, loads addresses within cut
  2. Calculate route → Walking route service uses nearest-neighbor from volunteer GPS position
  3. GPS tracking → Auto-submit points every 10s, calculate distance with haversine
  4. Record visit → Create CanvassVisit with outcome, update Address support level, update session progress
  5. End session → Mark session COMPLETED, end tracking session, calculate final stats
  6. Admin oversight → View active sessions, activity feed, cut progress, volunteer leaderboard

Database Models

CanvassSession Model

See CanvassSession Model Documentation for full schema.

Key Fields:

  • userId: Foreign key to volunteer User
  • cutId: Foreign key to Cut (territory)
  • shiftId: Optional foreign key to Shift (if started from shift)
  • status: ACTIVE | COMPLETED | ABANDONED
  • startedAt: Session start timestamp
  • endedAt: Session end timestamp (null while active)
  • totalVisits: Count of CanvassVisit records
  • completionPercentage: Auto-calculated from cut progress

Status Lifecycle:

ACTIVE (session running)
  ↓ (volunteer ends session)
COMPLETED

OR

ACTIVE (session running > 12 hours)
  ↓ (auto-cleanup cron)
ABANDONED

CanvassVisit Model

See CanvassVisit Model Documentation for full schema.

Key Fields:

  • sessionId: Foreign key to CanvassSession
  • userId: Foreign key to volunteer User
  • addressId: Foreign key to Address (specific unit visited)
  • outcome: Visit result (7 types)
  • supportLevel: Updated support level (LEVEL_1-4 or null)
  • signRequested: Boolean - resident wants lawn/window sign
  • notes: Free-text canvass notes
  • visitedAt: Visit timestamp
  • durationSeconds: Time spent at door (auto-calculated)

Visit Outcome Enum:

enum VisitOutcome {
  NOT_HOME         // Nobody answered door
  REFUSED          // Refused to speak
  MOVED            // Resident moved away
  ALREADY_VOTED    // Already voted (GOTV)
  SPOKE_WITH       // Had conversation
  LEFT_LITERATURE  // Left campaign material
  COME_BACK_LATER  // Asked to return later
}

Related Models:

API Endpoints

See Canvass Backend Module Documentation for full API reference.

Volunteer Endpoints:

Method Endpoint Auth Description
GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts with cut assignments
GET /api/map/canvass/volunteer/stats Any logged-in user Get volunteer canvass statistics
GET /api/map/canvass/volunteer/visits Any logged-in user List own canvass visits with pagination
POST /api/map/canvass/sessions Any logged-in user Start new canvass session
PATCH /api/map/canvass/sessions/:id Any logged-in user Update session (end session)
GET /api/map/canvass/sessions/:id/addresses Any logged-in user Get addresses within session cut
POST /api/map/canvass/sessions/:id/route Any logged-in user Calculate walking route
POST /api/map/canvass/visits Any logged-in user Record single visit
POST /api/map/canvass/visits/bulk Any logged-in user Record multiple visits (batch)
PATCH /api/map/canvass/volunteer/locations/:id Any logged-in user Update location from canvass

Admin Endpoints:

Method Endpoint Auth Description
GET /api/map/canvass/admin/activity MAP_ADMIN Get recent canvass activity feed
GET /api/map/canvass/admin/sessions MAP_ADMIN List active canvass sessions
GET /api/map/canvass/admin/visits MAP_ADMIN List all canvass visits with filters
GET /api/map/canvass/admin/progress MAP_ADMIN Get cut completion progress
GET /api/map/canvass/admin/leaderboard MAP_ADMIN Get volunteer visit leaderboard

Configuration

Environment Variables

Variable Type Default Description
CANVASS_SESSION_TIMEOUT_HOURS number 12 Auto-abandon active sessions after N hours
CANVASS_VISIT_RATE_LIMIT number 30 Max visits per minute per IP

Rate Limiting

Visit Recording Rate Limit:

  • Limit: 30 visits/min per IP address
  • Window: 60 seconds
  • Redis Prefix: rl:canvass-visit:
  • Purpose: Prevent accidental bulk submissions from GPS auto-submit bugs

Session Auto-Cleanup

Abandoned Session Detection:

System automatically marks sessions as ABANDONED if:

  • Status: ACTIVE
  • Started: >12 hours ago
  • No Activity: No visits in last hour

Cleanup Schedule:

  • Startup: On API server startup (check all active sessions)
  • Cron: Every hour at :00 (setInterval)
// api/src/server.ts
setInterval(async () => {
  await canvassService.cleanupAbandonedSessions();
}, 60 * 60 * 1000); // 1 hour

Admin Workflow

Viewing Active Sessions

Step 1: Navigate to Canvass Dashboard

Navigate to Map → Canvass Dashboard in the admin sidebar.

![CanvassDashboardPage Screenshot Placeholder]

Step 2: View Active Sessions

Active Sessions card displays:

  • Volunteer Name: Who is canvassing
  • Cut Name: Territory being canvassed
  • Start Time: When session started
  • Duration: Time elapsed (live updating)
  • Visits: Number of visits recorded
  • Status: ACTIVE badge

Step 3: View Session Details

Click session row to view:

  • Volunteer GPS Location: Last known position on map
  • Route: Walking route polyline
  • Visited Addresses: Green markers
  • Unvisited Addresses: Blue markers
  • Recent Visits: Last 10 visits with outcomes

Monitoring Canvass Activity

Step 1: View Activity Feed

Recent Activity section displays:

  • Volunteer Name: Who recorded visit
  • Address: Location visited
  • Outcome: Visit result (icon + label)
  • Support Level: Updated support level (color-coded)
  • Time Ago: "5 minutes ago"

Step 2: Filter Activity

Use filters:

  • Date Range: Last hour / day / week / month
  • Outcome: Filter by specific outcome type
  • Volunteer: Filter by volunteer name
  • Cut: Filter by territory

Step 3: Export Activity

Click Export CSV to download activity feed for reporting.

Tracking Cut Completion

Step 1: View Cut Progress

Cut Progress card displays:

  • Cut Name: Territory name
  • Total Addresses: Count of addresses in cut
  • Visited: Count of addresses with CanvassVisit records
  • Completion: Percentage (progress bar)
  • Last Activity: Time since last visit

Step 2: View Detailed Progress

Click cut row to view:

  • Address List: All addresses in cut with visit status
  • Visit Heatmap: Map showing visited (green) vs unvisited (blue)
  • Outcome Breakdown: Pie chart of visit outcomes
  • Volunteer Breakdown: Who visited which addresses

Volunteer Leaderboard

Step 1: View Leaderboard

Leaderboard card displays:

  • Rank: 1st, 2nd, 3rd place
  • Volunteer Name: Volunteer name
  • Total Visits: Visit count
  • Doors/Hour: Efficiency metric
  • Top Outcome: Most common outcome

Step 2: Filter by Time Period

Toggle time period:

  • Today: Visits since midnight
  • This Week: Visits since Monday
  • This Month: Visits since 1st of month
  • All Time: Total visits

Volunteer Workflow

Starting a Canvass Session

Step 1: Login

Login at /login with volunteer account (or use TEMP account from shift signup).

Step 2: View Assignments

Navigate to Volunteer → My Assignments.

Step 3: Select Shift

Click Start Canvass on a shift with cut assignment.

Step 4: Grant GPS Permission

Browser requests geolocation permission. Click Allow.

Step 5: Start Session

System redirects to /volunteer/canvass/:cutId (full-screen map).

System will:

  1. Create CanvassSession (status=ACTIVE)
  2. Create TrackingSession (linked 1:1)
  3. Load addresses within cut polygon
  4. Calculate walking route from current GPS position
  5. Start GPS auto-tracking (submit points every 10s)

Following Walking Route

Step 1: View Route on Map

Map displays:

  • Blue Polyline: Optimized walking route
  • Blue Markers: Unvisited addresses (ordered by route)
  • Green Markers: Visited addresses
  • Red Marker: Current GPS position (live updating)

Step 2: Navigate to First Address

Follow route to nearest unvisited address. Route recalculates when you move.

Step 3: View Address Details

Tap marker to view:

  • Address: Street address + unit number
  • Resident Name: First/Last name (if available)
  • Support Level: Previous support level (if available)
  • Last Visit: Previous visit outcome + date (if applicable)

Recording a Visit

Step 1: Knock on Door

Approach address and knock/ring doorbell.

Step 2: Open Visit Recording Form

Tap Record Visit button in bottom toolbar. Bottom sheet slides up.

Step 3: Select Outcome

Choose visit outcome:

  • Not Home: Nobody answered
  • Refused: Refused to speak
  • Moved: Resident moved away
  • Already Voted: Already voted (GOTV campaigns)
  • Spoke With: Had conversation
  • Left Literature: Left campaign material
  • Come Back Later: Asked to return later

Step 4: Update Support Level (if applicable)

For "Spoke With" outcome, select support level:

  • Level 1 (Strong): Green badge
  • Level 2 (Leaning): Yellow badge
  • Level 3 (Undecided): Gray badge
  • Level 4 (Opposed): Red badge

Step 5: Sign Request (optional)

Toggle Sign Requested if resident wants lawn/window sign.

Step 6: Add Notes (optional)

Enter free-text notes (e.g., "Asked about healthcare policy", "Concerned about taxes").

Step 7: Save Visit

Tap Save Visit. System will:

  1. Create CanvassVisit record with outcome + timestamp
  2. Update Address with new support level + sign status + notes
  3. Increment session.totalVisits count
  4. Update cut.completionPercentage
  5. Create LocationHistory audit record
  6. Submit GPS trackpoint with eventType=VISIT_RECORDED
  7. Update marker to green (visited)
  8. Recalculate walking route (exclude visited address)

Ending a Canvass Session

Step 1: Finish Route

Complete visits for all addresses (or as many as possible).

Step 2: End Session

Tap End Session button in header.

Step 3: Confirm

Confirmation modal displays session summary:

  • Duration: Total session time
  • Visits: Number of visits recorded
  • Distance: Total distance walked (from GPS tracking)
  • Doors/Hour: Efficiency metric

Step 4: Submit

Tap End Session. System will:

  1. Update CanvassSession (status=COMPLETED, endedAt=now)
  2. End TrackingSession (isActive=false, endedAt=now)
  3. Calculate final stats (totalVisits, totalDistanceM)
  4. Redirect to /volunteer/activity (visit history page)

Viewing Visit History

Step 1: Navigate to My Activity

Navigate to Volunteer → My Activity.

Step 2: View Visit List

Table displays:

  • Address: Location visited
  • Outcome: Visit result (icon + label)
  • Support Level: Updated support level
  • Visit Date: Formatted date/time
  • Notes: Canvassing notes (truncated)

Step 3: Filter Visits

Use filters:

  • Date Range: Last week / month / all time
  • Outcome: Filter by specific outcome
  • Support Level: Filter by support level

Step 4: View Session History

Navigate to My Routes to view:

  • Session List: Past canvass sessions
  • Session Map: GPS route polyline + visited markers
  • Session Stats: Duration, visits, distance, doors/hour

Code Examples

Start Canvass Session (Backend)

// api/src/modules/map/canvass/canvass.service.ts
async startSession(userId: string, data: StartSessionInput) {
  const { cutId, shiftId, startLat, startLng } = data;

  // Check for existing active session
  const existing = await prisma.canvassSession.findFirst({
    where: { userId, status: CanvassSessionStatus.ACTIVE },
  });

  if (existing) {
    throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');
  }

  // Create session + tracking session in transaction
  const session = await prisma.$transaction(async (tx) => {
    const canvassSession = await tx.canvassSession.create({
      data: {
        userId,
        cutId,
        shiftId,
        status: CanvassSessionStatus.ACTIVE,
      },
    });

    if (startLat && startLng) {
      await tx.trackingSession.create({
        data: {
          userId,
          canvassSessionId: canvassSession.id,
          lastLatitude: new Prisma.Decimal(startLat),
          lastLongitude: new Prisma.Decimal(startLng),
          lastRecordedAt: new Date(),
        },
      });
    }

    return canvassSession;
  });

  setActiveCanvassSessions(
    await prisma.canvassSession.count({
      where: { status: CanvassSessionStatus.ACTIVE },
    })
  );

  return session;
}

Calculate Walking Route (Backend)

// api/src/modules/map/canvass/canvass-route.service.ts
export function calculateWalkingRoute(
  locations: RouteLocation[],
  startLat?: number,
  startLng?: number,
  cutGeojson?: string,
): RouteResult {
  if (locations.length === 0) {
    return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };
  }

  // Determine starting point
  let currentLat: number;
  let currentLng: number;

  if (startLat !== undefined && startLng !== undefined) {
    currentLat = startLat;
    currentLng = startLng;
  } else if (cutGeojson) {
    const polygons = parseGeoJsonPolygon(cutGeojson);
    const centroid = calculateCentroid(polygons[0]!);
    currentLat = centroid.lat;
    currentLng = centroid.lng;
  } else {
    // Use first location as starting point
    currentLat = locations[0]!.latitude;
    currentLng = locations[0]!.longitude;
  }

  const remaining = [...locations];
  const ordered: RouteLocation[] = [];
  let totalDistance = 0;

  // Nearest-neighbor algorithm
  while (remaining.length > 0) {
    let nearestIdx = 0;
    let nearestDist = Infinity;

    for (let i = 0; i < remaining.length; i++) {
      const loc = remaining[i]!;
      const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);
      if (dist < nearestDist) {
        nearestDist = dist;
        nearestIdx = i;
      }
    }

    const nearest = remaining.splice(nearestIdx, 1)[0]!;
    ordered.push(nearest);
    totalDistance += nearestDist;
    currentLat = nearest.latitude;
    currentLng = nearest.longitude;
  }

  const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min
  const MINUTES_PER_DOOR = 2;
  const walkingMinutes = totalDistance / WALKING_SPEED_MPS;
  const doorMinutes = ordered.length * MINUTES_PER_DOOR;
  const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);

  return {
    orderedLocations: ordered,
    totalDistanceMeters: Math.round(totalDistance),
    estimatedMinutes,
  };
}

Record Visit (Backend)

// api/src/modules/map/canvass/canvass.service.ts
async recordVisit(userId: string, data: RecordVisitInput) {
  const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;

  // Verify session ownership and active status
  const session = await prisma.canvassSession.findFirst({
    where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },
  });

  if (!session) {
    throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');
  }

  // Create visit + update address in transaction
  const visit = await prisma.$transaction(async (tx) => {
    const canvassVisit = await tx.canvassVisit.create({
      data: {
        sessionId,
        userId,
        addressId,
        outcome,
        supportLevel,
        signRequested: signRequested ?? false,
        notes,
      },
    });

    // Update address with new data
    if (supportLevel || signRequested !== undefined || notes) {
      await tx.address.update({
        where: { id: addressId },
        data: {
          ...(supportLevel && { supportLevel }),
          ...(signRequested !== undefined && { sign: signRequested }),
          ...(notes && { notes }),
        },
      });
    }

    // Increment session visit count
    await tx.canvassSession.update({
      where: { id: sessionId },
      data: { totalVisits: { increment: 1 } },
    });

    // Update cut completion percentage
    if (session.cutId) {
      const cutId = session.cutId;
      const totalAddresses = await tx.address.count({
        where: {
          location: {
            // Point-in-polygon query omitted for brevity
          },
        },
      });

      const visitedAddresses = await tx.canvassVisit.count({
        where: {
          session: { cutId },
        },
      });

      const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);

      await tx.cut.update({
        where: { id: cutId },
        data: { completionPercentage },
      });
    }

    return canvassVisit;
  });

  recordCanvassVisit(outcome);

  return visit;
}

GPS Auto-Tracking (Frontend)

// admin/src/components/canvass/GPSTracker.tsx
useEffect(() => {
  if (!session || !geolocationEnabled) return;

  const watchId = navigator.geolocation.watchPosition(
    (position) => {
      const point = {
        latitude: position.coords.latitude,
        longitude: position.coords.longitude,
        accuracy: position.coords.accuracy,
        recordedAt: new Date().toISOString(),
      };

      // Add to local buffer
      setPointsBuffer((prev) => [...prev, point]);

      // Update current position
      setCurrentPosition([point.latitude, point.longitude]);
    },
    (error) => {
      console.error('GPS error:', error);
      message.error('GPS tracking failed');
    },
    {
      enableHighAccuracy: true,
      maximumAge: 0,
      timeout: 10000,
    }
  );

  // Submit buffered points every 10 seconds
  const interval = setInterval(async () => {
    if (pointsBuffer.length === 0) return;

    try {
      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
        points: pointsBuffer,
      });

      setPointsBuffer([]);
    } catch (error) {
      console.error('Failed to submit GPS points:', error);
    }
  }, 10000);

  return () => {
    navigator.geolocation.clearWatch(watchId);
    clearInterval(interval);
  };
}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);

Visit Recording Form (Frontend)

// admin/src/components/canvass/VisitRecordingForm.tsx
const handleSubmit = async (values: any) => {
  try {
    await api.post('/map/canvass/visits', {
      sessionId: session.id,
      addressId: selectedAddress.id,
      outcome: values.outcome,
      supportLevel: values.supportLevel,
      signRequested: values.signRequested,
      notes: values.notes,
    });

    message.success('Visit recorded');
    form.resetFields();
    onVisitRecorded();
  } catch (error) {
    message.error('Failed to record visit');
  }
};

Troubleshooting

Issue: Walking Route Not Optimal

Symptoms:

  • Route backtracks frequently
  • Total distance much longer than expected
  • Route doesn't start from volunteer GPS position

Causes:

  • Nearest-neighbor algorithm is greedy (not globally optimal)
  • Starting position not provided (defaults to cut centroid)
  • GPS accuracy poor (volunteer position inaccurate)

Solutions:

  1. Use volunteer GPS position as start:
// Always pass volunteer GPS position to route calculation
const route = await calculateWalkingRoute(
  locations,
  currentLat,
  currentLng,
  cut.geojson
);
  1. Consider alternative algorithms:

For better optimization, use 2-opt or genetic algorithms (computationally expensive):

// Install optimization library
npm install routing-js

// Use 2-opt algorithm
import { twoOpt } from 'routing-js';
const optimized = twoOpt(locations, distanceMatrix);
  1. Pre-optimize routes for shifts:

Admin can pre-calculate optimal routes and assign to volunteers:

// Calculate route once, store in Shift model
const route = await calculateWalkingRoute(locations);
await prisma.shift.update({
  where: { id: shiftId },
  data: { preCalculatedRoute: JSON.stringify(route) },
});

Issue: Session Auto-Abandoned Prematurely

Symptoms:

  • Active session marked ABANDONED while volunteer still canvassing
  • Session timeout after <12 hours
  • Volunteer can't record visits after timeout

Causes:

  • CANVASS_SESSION_TIMEOUT_HOURS set too low
  • Volunteer paused for lunch/break (no activity for >1 hour)
  • System clock drift

Solutions:

  1. Increase timeout:
# In .env
CANVASS_SESSION_TIMEOUT_HOURS=24  # Was 12, increase to 24
  1. Record "heartbeat" visits:

Add periodic "still active" ping to prevent timeout:

// Volunteer app sends heartbeat every 30 minutes
setInterval(async () => {
  await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);
}, 30 * 60 * 1000);
  1. Allow session resumption:

Let volunteers resume ABANDONED sessions:

// Backend: Add resume endpoint
async resumeSession(userId: string, sessionId: string) {
  const session = await prisma.canvassSession.findFirst({
    where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },
  });

  if (!session) {
    throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');
  }

  return prisma.canvassSession.update({
    where: { id: sessionId },
    data: { status: CanvassSessionStatus.ACTIVE },
  });
}

Issue: GPS Tracking Draining Battery

Symptoms:

  • Volunteer phone battery drains rapidly
  • Phone overheats during canvassing
  • GPS tracking fails after 2-3 hours

Causes:

  • enableHighAccuracy uses GPS + WiFi + cellular (power-hungry)
  • Watchposition submits too frequently (every second)
  • Screen stays on during entire session

Solutions:

  1. Reduce GPS accuracy:
navigator.geolocation.watchPosition(
  callback,
  errorCallback,
  {
    enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)
    maximumAge: 5000,          // Cache position for 5s
    timeout: 30000,            // Longer timeout
  }
);
  1. Reduce submission frequency:
// Submit GPS points every 30s instead of 10s
const SUBMIT_INTERVAL_MS = 30000; // Was 10000
  1. Pause tracking during breaks:

Add "Pause Tracking" button to stop GPS watchPosition:

const pauseTracking = () => {
  navigator.geolocation.clearWatch(watchId);
  setTrackingPaused(true);
};

const resumeTracking = () => {
  // Start watchPosition again
  setTrackingPaused(false);
};

Performance Considerations

Visit Recording Rate Limiting

Prevent Abuse:

Rate limit prevents accidental bulk submissions:

// api/src/middleware/rate-limit.ts
const canvassVisitLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'rl:canvass-visit:',
  points: 30,      // 30 visits
  duration: 60,    // per 60 seconds
  blockDuration: 300, // block for 5 minutes if exceeded
});

Legitimate Use Cases:

  • Bulk data entry: Admin can bypass rate limit for importing historical data
  • Offline sync: Mobile app queues visits offline, submits when online (batch endpoint)

Session Cleanup Performance

Efficient Abandoned Session Query:

-- Index for abandoned session cleanup
CREATE INDEX idx_canvass_sessions_abandoned ON "CanvassSession" ("status", "startedAt")
WHERE status = 'ACTIVE';

-- Efficient query
SELECT id FROM "CanvassSession"
WHERE status = 'ACTIVE'
  AND "startedAt" < NOW() - INTERVAL '12 hours';

Cut Completion Calculation

Avoid N+1 Queries:

// Inefficient: query per cut
for (const cut of cuts) {
  const visited = await prisma.canvassVisit.count({
    where: { session: { cutId: cut.id } },
  });
  const total = await getAddressesInCut(cut.id).length;
  cut.completionPercentage = (visited / total) * 100;
}

// Efficient: single aggregation query
const completionStats = await prisma.canvassSession.groupBy({
  by: ['cutId'],
  where: { status: CanvassSessionStatus.COMPLETED },
  _count: { visits: true },
});

Backend Modules:

Frontend Pages:

Database:

Features:

  • Cuts — Territory boundaries for canvassing
  • Shifts — Shift-based canvass scheduling
  • Tracking — GPS tracking system
  • Locations — Address management