7.2 KiB

Map Models

Overview

The Map module provides building-level and unit-level location management with multi-provider geocoding, volunteer shift scheduling, GeoJSON polygon cuts for map filtering, and comprehensive audit trails.

Models (7):

  • Location — Building-level with lat/lng, NAR integration
  • Address — Unit-level with support levels
  • LocationHistory — Audit trail (7 action types)
  • Shift — Volunteer shifts with cut relation
  • ShiftSignup — Signup tracking
  • Cut — GeoJSON polygon overlays
  • MapSettings — Singleton configuration

Key Features:

  • Building vs unit architecture (1 Location → many Addresses)
  • Multi-provider geocoding (6 providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS)
  • NAR 2025 Canadian electoral data import
  • Spatial indexing (latitude/longitude composite index)
  • GeoJSON polygon storage for cuts
  • Walk sheet generation with QR codes
  • CSV import/export

See Schema Reference for complete field listings.


Building vs Unit Architecture

Location = Building-level data:

  • Single lat/lng coordinate
  • Street address (no unit number)
  • Building type (SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL)
  • Total units count
  • Building notes (access codes, manager contact)

Address = Unit-level data:

  • Unit number (apartment #, suite #)
  • Occupant name/email/phone
  • Support level (1-4)
  • Sign request flag
  • Canvassing notes

Relationship: Location ||--o{ Address (one-to-many)

Example:

// 1 Location: 123 Main St (4-unit apartment building)
const location = {
  address: '123 Main St',
  latitude: 53.5461,
  longitude: -113.4938,
  buildingType: 'MULTI_UNIT',
  totalUnits: 4,
};

// 4 Addresses: Units 101-104
const addresses = [
  { locationId, unitNumber: '101', firstName: 'Alice', supportLevel: '4' },
  { locationId, unitNumber: '102', firstName: 'Bob', supportLevel: '3' },
  { locationId, unitNumber: '103', firstName: 'Carol', supportLevel: '2' },
  { locationId, unitNumber: '104', firstName: 'Dave', supportLevel: '1' },
];

Geocoding Providers

enum GeocodeProvider {
  GOOGLE        // Google Maps Geocoding API
  MAPBOX        // Mapbox Geocoding API
  NOMINATIM     // OpenStreetMap Nominatim
  PHOTON        // Photon (OSM-based)
  LOCATIONIQ    // LocationIQ (OSM-based)
  ARCGIS        // ArcGIS Geocoding Service
  UNKNOWN       // Manually entered or unknown source
}

Provider Priority:

  1. Google (highest accuracy, paid)
  2. Mapbox (high accuracy, paid)
  3. ArcGIS (high accuracy, free tier)
  4. Nominatim (medium accuracy, free)
  5. Photon (medium accuracy, free)
  6. LocationIQ (medium accuracy, free tier)

Confidence Score: 0-100 (stored in geocodeConfidence field)


NAR 2025 Import

NAR = National Address Register (Canadian electoral data)

Import Features:

  • Streams large CSV files (no memory limit)
  • Joins Location + Address files on LOC_GUID
  • Converts BG_X/BG_Y (EPSG:3347 Lambert projection) → lat/lng
  • Province selector (codes 10-62)
  • City/postal/cut filters
  • Residential-only toggle (buildingUse = 1)

New Location Fields:

  • postalCode — Canadian postal code
  • province — Province code (e.g., "AB")
  • federalDistrict — Federal electoral district
  • buildingUse — NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown)
  • locGuid — NAR LOC_GUID (unique)

New Address Fields:

  • addrGuid — NAR ADDR_GUID (unique)

LocationHistory Actions

enum LocationHistoryAction {
  CREATED          // Location created
  UPDATED          // Location updated
  GEOCODED         // Single location geocoded
  BULK_GEOCODED    // Batch geocode operation
  MOVED_ON_MAP     // Dragged on admin map
  IMPORTED_CSV     // CSV import
  IMPORTED_NAR     // NAR import
}

Audit Fields:

  • field — Which field changed (e.g., "latitude")
  • oldValue — Previous value
  • newValue — New value
  • metadata — JSON with provider, confidence, etc.

Cut GeoJSON Storage

Cut stores GeoJSON polygon coordinates:

{
  "type": "Polygon",
  "coordinates": [
    [
      [-113.5, 53.5],
      [-113.4, 53.5],
      [-113.4, 53.6],
      [-113.5, 53.6],
      [-113.5, 53.5]
    ]
  ]
}

Bounds: Calculated bounding box for quick filtering:

{
  "north": 53.6,
  "south": 53.5,
  "east": -113.4,
  "west": -113.5
}

Shift Status Workflow

stateDiagram-v2
    [*] --> OPEN : Create shift
    OPEN --> FULL : currentVolunteers >= maxVolunteers
    OPEN --> CANCELLED : Admin cancels
    FULL --> OPEN : Volunteer cancels (currentVolunteers < maxVolunteers)
    FULL --> CANCELLED : Admin cancels
    CANCELLED --> [*]

Common Queries

Create Location with Geocoding

const location = await prisma.location.create({
  data: {
    address: '123 Main St, Edmonton, AB',
    latitude: 53.5461,
    longitude: -113.4938,
    geocodeProvider: GeocodeProvider.GOOGLE,
    geocodeConfidence: 95,
    buildingType: BuildingType.SINGLE_FAMILY,
    totalUnits: 1,
    createdByUserId: user.id,
    history: {
      create: {
        userId: user.id,
        action: LocationHistoryAction.GEOCODED,
        metadata: { provider: 'google', confidence: 95 },
      },
    },
  },
});

Find Locations in Bounding Box

const locations = await prisma.location.findMany({
  where: {
    latitude: { gte: 53.5, lte: 53.6 },
    longitude: { gte: -113.5, lte: -113.4 },
  },
  include: { addresses: true },
});

Create Shift with Cut

const shift = await prisma.shift.create({
  data: {
    title: 'Weekend Canvassing - Downtown',
    date: new Date('2025-02-15'),
    startTime: '10:00',
    endTime: '14:00',
    maxVolunteers: 10,
    isPublic: true,
    cutId: cut.id,  // Assign to cut
  },
});

Public Shift Signup (Creates TEMP User)

// 1. Create TEMP user with random password
const tempPassword = generatePassword(); // "SwiftEagle42"
const tempUser = await prisma.user.create({
  data: {
    email: 'volunteer@example.com',
    password: await bcrypt.hash(tempPassword, 10),
    name: 'Jane Volunteer',
    role: UserRole.TEMP,
    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    expireDays: 30,
  },
});

// 2. Create shift signup
await prisma.shiftSignup.create({
  data: {
    shiftId: shift.id,
    userId: tempUser.id,
    userEmail: 'volunteer@example.com',
    userName: 'Jane Volunteer',
    signupSource: SignupSource.PUBLIC,
  },
});

// 3. Send confirmation email with temp password
await emailService.send({
  template: 'shift-signup-confirmation',
  variables: {
    USER_NAME: 'Jane Volunteer',
    SHIFT_TITLE: shift.title,
    IS_NEW_USER: 'true',
    TEMP_PASSWORD: tempPassword,
    // ...
  },
});