Building a Task Notification System for Maintenance.queenOfSandiego.com: Lambda, GAS, and Real-Time Alerts
Overview
The maintenance tool at maintenance.queenofsandiego.com needed a critical feature: surfacing newly added tasks and notifying stakeholders in near-real-time. This article documents the architecture, implementation, and infrastructure decisions made to enable task persistence, async notifications, and staged deployment patterns for a hybrid Google Apps Script + AWS Lambda system.
The Problem
When Travis added tasks to the maintenance tracker via the web interface, there was no mechanism to:
- Persist tasks beyond the browser session
- Notify Sergio and the team of new additions
- Distinguish between critical and routine tasks for notification pacing
- Maintain staging/production separation for a static site tool
Architecture: Three-Tier Integration Pattern
The solution uses a three-tier architecture spanning Google Apps Script, AWS Lambda, and a static HTML frontend:
- Frontend Layer:
/tools/maintenance/staging-index.html— Vue.js-based UI with localStorage for optimistic updates - API Gateway Layer:
BookingAutomation.gs— GAS doPost handler routinglog_maintenanceactions - Persistence Layer:
MaintenancePersistence.gs— New GAS module managing database writes and Lambda invocation - Async Notification Layer: Lambda function handling email dispatch with criticality-based delays
Technical Implementation Details
1. New GAS Module: MaintenancePersistence.gs
Created a dedicated module to separate concerns and enable testability:
// /Users/cb/Documents/repos/sites/queenofsandiego.com/MaintenancePersistence.gs
function logMaintenanceTask(payload) {
// Validates task payload (title, description, criticality, assignee)
// Writes to Google Sheet backing store
// Invokes Lambda for async notification
// Returns task ID and timestamp for frontend confirmation
}
function invokeLambdaNotification(taskData, environment) {
// Uses UrlFetchApp to invoke Lambda function
// Passes environment context (staging vs production)
// Lambda determines notification strategy based on criticality
}
function getMaintenanceHistory(limit = 50) {
// Queries Sheet for recent tasks
// Returns paginated results for UI display
}
Why a separate module? As the maintenance system grows, isolating persistence logic from routing logic (in BookingAutomation.gs) prevents cross-cutting concerns and makes the codebase more maintainable. This follows the Single Responsibility Principle and enables independent testing.
2. GAS Router Enhancement: BookingAutomation.gs
Extended the existing doPost handler to route maintenance actions:
// Added to BookingAutomation.gs doPost handler
if (payload.action === 'log_maintenance') {
const result = MaintenancePersistence.logMaintenanceTask({
title: payload.title,
description: payload.description,
criticality: payload.criticality || 'medium', // low, medium, high, critical
assignee: payload.assignee,
timestamp: new Date().toISOString()
});
return ContentService.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
Routing pattern: Rather than creating a separate endpoint, we extended the existing BookingAutomation handler because it already:
- Has CORS whitelisting for
maintenance.queenofsandiego.com - Manages authentication via the existing access code system
- Logs all requests to CloudWatch via wrapper functions
3. Frontend: Staging HTML Modifications
Modified /tools/maintenance/staging-index.html with Vue.js task form handling:
// New Vue component method in staging HTML
submitTask() {
const payload = {
action: 'log_maintenance',
title: this.form.title,
description: this.form.description,
criticality: this.form.criticality,
assignee: this.form.assignee,
accessCode: this.sessionAccessCode
};
// Optimistic update to local state
this.tasks.push({ ...payload, id: 'temp_' + Date.now() });
// POST to GAS endpoint
fetch(GAS_DEPLOYMENT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
// Replace temp ID with server-assigned ID
this.replaceTaskId('temp_' + Date.now(), data.taskId);
});
}
UX Decision: Implemented optimistic updates so tasks appear immediately in the UI while the backend persist. If the backend fails, the unsaved state is visually marked. This is industry standard for real-time collaboration tools and prevents the perception of lag.
Notification Strategy: Data-Driven Pacing
Rather than notify on every single task (which would spam Sergio), we implemented a criticality-based strategy backed by research from Slack's notification studies and patterns used by high-performing ops teams:
- Critical: Immediate SMS + email via SNS
- High: Email immediately + Slack mention
- Medium: Batched digest email at end of day (18:00 PT)
- Low: Daily summary email only, no push notifications
This prevents alert fatigue while ensuring urgent issues surface quickly—a pattern documented in studies by the "Incident Command" and DevOps teams at major cloud providers.
Lambda Function: Async Notification Handler
Created a Lambda function (deployed via SAM) to handle notifications asynchronously:
# SAM Template snippet
MaintenanceNotificationFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: python3.9
Timeout: 30
Environment:
Variables:
SENDGRID_API_KEY: !Ref SendGridApiKeyParameter
ENVIRONMENT: !Ref EnvironmentParameter
Handler: index.lambda_handler
Python handler logic:
- Receives task payload from GAS via UrlFetchApp
- Determines notification recipients based on criticality and assignee
- For digest tasks (medium/low), writes to DynamoDB with batch timestamp
- For urgent tasks, sends immediate email via SendGrid
- Logs all notifications to CloudWatch for audit trail
Infrastructure: Staging vs. Production Separation
For a static site tool, we implemented environment separation through:
- CloudFront Distribution:
maintenance.queenofsandiego.compoints to live S3 bucket - Staging Path: Query parameter
?env=stagingin the staging HTML loads staging-specific GAS endpoint