← Back to Blog

Project 03 — SA Incident Tracker

Building a zero-cost community safety reporting tool for Durban / KZN — anonymous incident submission, admin moderation, live CCTV feeds, and a canvas-based photo blur tool. GAS + Sheets as the backend.

The Problem

The existing Elysium Homewatch incident tracker was functional but built informally. The goal was a clean rebuild — same features, documented architecture, full MD Works brand — that could serve as a genuine portfolio piece and a better tool for the community.

The additional requirement was to expand beyond crime reporting to cover the kinds of incidents a neighbourhood watch actually deals with day to day: fire and smoke, water leaks, unattended animals, road hazards, and civil disruptions.

Architecture

The stack follows the MD Works zero-cost pattern:

  • Frontend — Vanilla JS, HTML, CSS on Cloudflare Pages
  • Backend — Google Apps Script Web App as a REST API
  • Database — Google Sheets with three tabs: Pending, Approved, Rejected
  • Photos — uploaded client-side to ImgBB, only URLs stored in Sheets
  • Map — Leaflet.js with CartoDB dark tiles

The moderation workflow is the core of the data model. Every public report lands in Pending. Admin approval moves the row to Approved (and makes it visible on the public map). Rejection moves it to Rejected. The findRowById and findReportSheet helpers locate any row across all three tabs by UUID.

The CORS Problem

GAS POST requests fail with a CORS preflight error if Content-Type: application/json is set. The browser treats that header as a non-simple request and sends an OPTIONS preflight — which GAS cannot respond to.

The fix is to omit the header entirely:

await fetch(CONFIG.GAS_URL, {
  method: 'POST',
  body:   JSON.stringify(payload), // no Content-Type header
});

Without it the browser treats the request as simple, skips the preflight, and GAS receives the JSON body correctly via e.postData.contents.

The Photo Blur Tool

The admin dashboard includes a canvas-based tool for redacting faces and licence plates before a report goes live. The non-obvious problem: drawing a cross-origin image directly onto a canvas taints it, causing toDataURL() to throw a SecurityError silently. The blur appears to work, but the output is never saved.

The fix is to fetch the image as a blob first:

const res     = await fetch(src);
const blob    = await res.blob();
const blobUrl = URL.createObjectURL(blob);
img.src = blobUrl;  // same-origin — canvas stays clean

The resulting data URL can then be re-uploaded to ImgBB and the new URL saved to the sheet, replacing the original.

Comments and Status

Each report has two additional fields added beyond the basic CRUD:

  • Comments — a JSON array stored in column I. Admin notes added via the dashboard are visible publicly in the map popup as “Updates”. This gives the community visibility into whether an incident is being acted on.
  • Report status — a string in column J: Active, Under Investigation, Resolved, False Alarm, or Duplicate. Shown as a colour-coded badge in both the admin queue and the public popup.

The public map includes an address search bar powered by Nominatim (OpenStreetMap’s geocoder). No API key required. The query is biased to South Africa with countrycodes=za. On a result the map pans and zooms to the location, dropping a brief pulse marker that fades after five seconds.

What This Project Demonstrates

  • Full CRUD with a multi-tab Google Sheets workflow
  • GAS as a zero-cost REST API with a POST dispatcher pattern
  • Canvas API with CORS blob workaround for image editing
  • Leaflet.js — custom markers, coordinate picker, Nominatim geocoding
  • ImgBB for client-side photo uploads — no backend file handling required
  • Client-side filtering (category + time) without re-fetching
  • PIN auth with GAS Script Properties for secure key storage

Live: sa-incident-tracker.pages.dev
GitHub: SA-incident-tracker


Next: Project 04 — SA Fuel Price API. Moving off the GAS stack for the first time into classical Node.js + PostgreSQL backend architecture.