Building a Real-Time Maintenance Task Notification System with Lambda, Google Apps Script, and CloudFront
When Travis added new maintenance tasks to the maintenance.queenofsandiego.com tool, they disappeared into the void. Sergio had no way to know tasks were added, and CB couldn't surface them without manual polling. This post documents the architecture we built to solve this: a Lambda-backed persistence layer that detects new tasks, evaluates their criticality, and routes notifications intelligently to the team.
The Problem: Silent Task Creation
The maintenance tool is a client-side HTML + JavaScript application served through CloudFront (distribution ID varies by environment). Users could add tasks, but those tasks only lived in browser session storage. New tasks weren't persisted, weren't indexed, and critically—weren't surfaced to the operations team. Sergio and CB needed real-time visibility into what was being tracked.
The challenge: How do you add server-side persistence and notifications to a tool that was designed as a pure frontend application, without breaking the existing user experience or creating a maintenance nightmare with separate staging/production deployments?
Architecture: Three-Layer Notification Stack
We implemented a three-layer system:
- Frontend Layer: Modified staging HTML at
/sites/queenofsandiego.com/tools/maintenance/staging-index.htmlto capture task creation events and POST to a GAS endpoint - Middleware Layer: Google Apps Script handlers in
BookingAutomation.gsthat routelog_maintenanceactions to Lambda and trigger immediate notifications - Backend Layer: AWS Lambda function that persists tasks to DynamoDB, evaluates criticality, and publishes to SNS for digest or immediate notifications
This three-layer approach kept the staging/production separation problem tractable: we only needed to change the frontend staging HTML and add routes to the existing GAS script. The Lambda function is environment-agnostic and can be updated independently.
Frontend Modification: Event Capture
The staging HTML already had a task creation form. We instrumented the form submission with an additional XMLHttpRequest that POSTs to the GAS endpoint:
function onTaskCreated(taskData) {
const payload = {
action: 'log_maintenance',
task_name: taskData.name,
task_description: taskData.description,
task_criticality: taskData.criticality || 'normal',
task_assigned_to: taskData.assigned_to || 'unassigned',
timestamp: new Date().toISOString(),
created_by: getCurrentUser()
};
fetch('https://script.google.com/macros/d/YOUR_SCRIPT_ID/usercontent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.catch(err => console.log('Task logged to backend'));
}
Note: This POST happens after the task is added to local storage, so network failures don't block the user experience. If the POST fails, the task still exists client-side.
GAS Middleware: Routing and Triggering
We added a new handler to BookingAutomation.gs within the existing doPost dispatcher. The dispatcher pattern was already in place for other actions; we just extended it:
function doPost(e) {
const params = JSON.parse(e.postData.contents);
if (params.action === 'log_maintenance') {
return handleMaintenanceLog(params);
}
// ... other actions
}
function handleMaintenanceLog(params) {
// 1. Persist to MaintenancePersistence.gs
MaintenancePersistence.logTask(params);
// 2. Invoke Lambda for backend processing
const lambdaPayload = {
action: 'persist_and_notify',
task: params,
environment: 'staging' // for routing notifications correctly
};
const response = UrlFetchApp.fetch(LAMBDA_ENDPOINT, {
method: 'post',
payload: JSON.stringify(lambdaPayload),
headers: { 'Authorization': 'Bearer ' + LAMBDA_INVOKE_TOKEN }
});
return ContentService.createTextOutput('OK');
}
The GAS script uses an IAM-authenticated service account to invoke the Lambda function. The service account's role ARN was configured to allow lambda:InvokeFunction on the maintenance Lambda ARN only.
Persistence Layer: MaintenancePersistence.gs
We created a new GAS library file at /sites/queenofsandiego.com/MaintenancePersistence.gs to maintain a server-side log of tasks. This isn't a replacement for the Lambda DynamoDB table; it's a lightweight backup and audit trail:
const MaintenancePersistence = {
logTask: function(taskData) {
const sheet = SpreadsheetApp
.openById(MAINTENANCE_SHEET_ID)
.getSheetByName('Task Log');
sheet.appendRow([
new Date(),
taskData.task_name,
taskData.task_description,
taskData.task_criticality,
taskData.created_by,
taskData.timestamp
]);
}
};
This serves two purposes: (1) If Lambda is unavailable, tasks are still logged, and (2) the sheet becomes a human-readable audit trail that Sergio can review without querying DynamoDB.
Lambda Backend: Criticality Evaluation and Routing
The Lambda function (deployed via CloudFormation, runtime Node.js 18) evaluates task criticality and makes notification decisions based on industry best practices for operational notifications:
- Critical tasks (e.g., "electrical hazard," "safety", "structural") → Immediate SMS + email to Sergio and CB
- High-priority tasks (e.g., "engine," "rigging," "bilge") → Email within 30 minutes, batched if multiple tasks arrive
- Normal/Low tasks → Daily digest email at 6 PM Pacific
Criticality keywords are evaluated against the task name and description using simple string matching. The Lambda function publishes to an SNS topic (jada-maintenance-notifications) with a message attribute priority that determines how each subscriber processes the message.
async function evaluateCriticality(taskData) {
const criticalKeywords = ['electrical', 'safety', 'hazard', 'structural'];
const highKeywords = ['engine', 'rigging', 'bilge', 'hull', 'mast'];
const combined =
(taskData.task_name + ' ' + taskData.task_description).toLowerCase();
if (criticalKeywords.some(kw => combined.includes(kw))) {
return 'CRITICAL';
}
if (highKeywords.some(kw => combined.includes(kw))) {
return 'HIGH';
}
return 'NORMAL';
}
Notification Routing and Testing
Because staging and production are currently the same deployment, we route staging notifications to jadasailing@gmail.com with a [STAGING] prefix. The email is sent from a SES-verified address (