6.9 KiB

Canvassing Models

Overview

The Canvassing module provides GPS-tracked volunteer canvassing with session management, visit recording, walking route algorithms, and automatic session abandonment.

Models (4):

  • CanvassSession — Session lifecycle (ACTIVE → COMPLETED/ABANDONED)
  • CanvassVisit — Visit recording with 7 outcome types
  • TrackingSession — GPS tracking integration
  • TrackPoint — GPS breadcrumb trail

Key Features:

  • Session lifecycle management (ACTIVE → COMPLETED/ABANDONED)
  • 7 visit outcomes (NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER)
  • Walking route algorithm (nearest-neighbor with haversine distance)
  • GPS breadcrumb trail with event markers
  • Support level tracking (1-4)
  • Sign request tracking
  • Session abandonment (12h timeout, auto-ABANDONED status)
  • Distance calculation (meters)

See Schema Reference for complete field listings.


Session Lifecycle

stateDiagram-v2
    [*] --> ACTIVE : Start session
    ACTIVE --> COMPLETED : End session (user action)
    ACTIVE --> ABANDONED : 12h timeout (cron)
    COMPLETED --> [*]
    ABANDONED --> [*]

Status: CanvassSessionStatus

  • ACTIVE — Session in progress
  • COMPLETED — Session ended by user
  • ABANDONED — Session inactive > 12h (auto-expired by cron)

Visit Outcomes

enum VisitOutcome {
  NOT_HOME          // No one home
  REFUSED           // Refused to talk
  MOVED             // Resident moved away
  ALREADY_VOTED     // Already voted (early voting)
  SPOKE_WITH        // Successful conversation
  LEFT_LITERATURE   // Left campaign literature
  COME_BACK_LATER   // Asked to come back later
}

Support Level Mapping:

  • Outcome: SPOKE_WITH → Record support level (1-4)
  • Outcome: REFUSED → Support level defaults to null or 1
  • Outcome: NOT_HOME → No support level

Walking Route Algorithm

Algorithm: Nearest-neighbor with haversine distance calculation

Steps:

  1. Get all unvisited addresses in cut
  2. Start from session start coordinates (or cut centroid)
  3. Find nearest unvisited address (haversine distance)
  4. Add to route, mark as visited
  5. Repeat from new position until all addresses visited

Implementation: api/src/modules/map/canvass/walking-route.service.ts

function calculateWalkingRoute(
  addresses: Address[],
  startLat: number,
  startLng: number,
  visitedAddressIds: string[]
): WalkingRoute {
  const unvisited = addresses.filter(a => !visitedAddressIds.includes(a.id));
  const route: Address[] = [];
  let currentLat = startLat;
  let currentLng = startLng;

  while (unvisited.length > 0) {
    // Find nearest unvisited address
    const nearest = findNearestAddress(currentLat, currentLng, unvisited);
    route.push(nearest);
    currentLat = nearest.location.latitude;
    currentLng = nearest.location.longitude;
    unvisited.splice(unvisited.indexOf(nearest), 1);
  }

  return {
    addresses: route,
    totalDistanceM: calculateTotalDistance(route),
  };
}

GPS Tracking

TrackingSession = One-to-one with CanvassSession

  • Stores total points, distance, last position
  • isActive flag for active tracking

TrackPoint = GPS breadcrumb

  • Latitude, longitude, accuracy
  • Event type markers (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

Event Flow:

sequenceDiagram
    participant Volunteer
    participant API
    participant GPS

    Volunteer->>API: POST /api/canvass/sessions (start session)
    API-->>Volunteer: sessionId
    loop Every 30 seconds
        GPS->>API: POST /api/tracking/:sessionId/points (lat, lng)
        API-->>GPS: 200 OK
    end
    Volunteer->>API: POST /api/canvass/visits (record visit)
    API->>GPS: POST /api/tracking/:sessionId/points (eventType: VISIT_RECORDED)
    Volunteer->>API: POST /api/canvass/sessions/:id/end
    API->>GPS: POST /api/tracking/:sessionId/points (eventType: SESSION_ENDED)
    API-->>Volunteer: session (status: COMPLETED)

Session Abandonment

Cron Job: Runs hourly via api/src/server.ts startup + interval

async function abandonStaleSessions() {
  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);

  await prisma.canvassSession.updateMany({
    where: {
      status: CanvassSessionStatus.ACTIVE,
      startedAt: { lt: twelveHoursAgo },
    },
    data: {
      status: CanvassSessionStatus.ABANDONED,
      endedAt: new Date(),
    },
  });
}

Trigger Conditions:

  • Status = ACTIVE
  • StartedAt < 12 hours ago
  • No explicit end by user

Common Queries

Start Canvass Session

const session = await prisma.canvassSession.create({
  data: {
    userId: user.id,
    cutId: cut.id,
    shiftId: shift?.id,
    status: CanvassSessionStatus.ACTIVE,
    startLatitude: startLat,
    startLongitude: startLng,
    trackingSession: {
      create: {
        userId: user.id,
        isActive: true,
      },
    },
  },
});

Record Visit

const visit = await prisma.canvassVisit.create({
  data: {
    addressId: address.id,
    userId: user.id,
    sessionId: session.id,
    shiftId: shift?.id,
    outcome: VisitOutcome.SPOKE_WITH,
    supportLevel: SupportLevel.LEVEL_4,
    signRequested: true,
    signSize: 'Large',
    notes: 'Very supportive, wants to volunteer',
    durationSeconds: 180,
  },
});

// Update address support level
await prisma.address.update({
  where: { id: address.id },
  data: {
    supportLevel: SupportLevel.LEVEL_4,
    sign: true,
    signSize: 'Large',
    notes: 'Very supportive, wants to volunteer',
    updatedByUserId: user.id,
  },
});

End Session

await prisma.canvassSession.update({
  where: { id: sessionId },
  data: {
    status: CanvassSessionStatus.COMPLETED,
    endedAt: new Date(),
    trackingSession: {
      update: {
        isActive: false,
        endedAt: new Date(),
      },
    },
  },
});

Get Walking Route

const session = await prisma.canvassSession.findUnique({
  where: { id: sessionId },
  include: {
    visits: { include: { address: true } },
  },
});

const visitedAddressIds = session.visits.map(v => v.addressId);

const addresses = await prisma.address.findMany({
  where: {
    location: {
      // Point-in-polygon check for cut
      latitude: { gte: cutBounds.south, lte: cutBounds.north },
      longitude: { gte: cutBounds.west, lte: cutBounds.east },
    },
  },
  include: { location: true },
});

const route = calculateWalkingRoute(
  addresses,
  session.startLatitude,
  session.startLongitude,
  visitedAddressIds
);