Unified Email Schedules Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Unify Tank Level Alert email sending into the Email Schedules system, making Email Schedules the single entry point for all email configuration.
Architecture: Add schedule_type and system_type columns to email_schedules to distinguish system schedules from user-created ones. The send-email Edge Function already has full tank alert support — the main work is database migration, frontend UI changes (System badge, delete protection), and refactoring the Tank Alert page to use the unified send-email function instead of the separate send-tank-level-alert function.
Tech Stack: React 19, TypeScript 5, Supabase (PostgreSQL 15.8), Deno Edge Functions, Microsoft Graph API
Design Document: docs/plans/2026-02-10-unified-email-schedules-design.md
Key Discovery
The send-email Edge Function (supabase/functions/send-email/index.ts) already has full tank level alert support:
fetchTankLevelAlertData()(line 1322) queries pending alerts, calculates tank levels, marks as notified- Both manual send (line 2661) and cron send (line 2214) check
metadata.type === "tank_level_alert" - Variable rendering for
{{summary_tank_level}}works (line 1636) - The system schedule record already exists (ID:
a0000000-0000-0000-0000-000000000001)
This means no Edge Function logic changes are needed — only database, frontend, and cleanup work.
Task 1: Database Migration — Add schedule_type and system_type
Files:
- Create:
supabase/migrations/20260210000000_unified_email_schedules.sql
Step 1: Write the migration SQL
-- =============================================================================
-- Migration: Unified Email Schedules
-- Adds schedule_type and system_type columns, migrates tank alert config,
-- and cleans up deprecated cfg_app_settings entries.
-- =============================================================================
-- 1. Add new columns to email_schedules
ALTER TABLE email_schedules
ADD COLUMN IF NOT EXISTS schedule_type text NOT NULL DEFAULT 'custom',
ADD COLUMN IF NOT EXISTS system_type text DEFAULT NULL;
-- Add check constraint for schedule_type
ALTER TABLE email_schedules
ADD CONSTRAINT email_schedules_schedule_type_check
CHECK (schedule_type IN ('custom', 'system'));
-- Add check constraint for system_type (nullable, but if set must be valid)
ALTER TABLE email_schedules
ADD CONSTRAINT email_schedules_system_type_check
CHECK (system_type IS NULL OR system_type IN ('tank_level_alert', 'calibration_reminder'));
-- 2. Update the existing system schedule record
UPDATE email_schedules
SET
schedule_type = 'system',
system_type = 'tank_level_alert'
WHERE id = 'a0000000-0000-0000-0000-000000000001';
-- 3. Migrate recipients from cfg_app_settings to email_schedules
-- Only update if the system schedule exists and cfg_app_settings has recipients
DO $$
DECLARE
v_recipients_json text;
v_recipients text[];
v_enabled text;
v_new_status text;
BEGIN
-- Get current tank_alert_recipients
SELECT value INTO v_recipients_json
FROM cfg_app_settings
WHERE key = 'tank_alert_recipients';
-- Parse JSON array to PostgreSQL text array
IF v_recipients_json IS NOT NULL AND v_recipients_json != '' THEN
SELECT array_agg(elem::text)
INTO v_recipients
FROM jsonb_array_elements_text(v_recipients_json::jsonb) AS elem;
-- Update the system schedule with migrated recipients
IF v_recipients IS NOT NULL AND array_length(v_recipients, 1) > 0 THEN
UPDATE email_schedules
SET to_recipients = v_recipients
WHERE id = 'a0000000-0000-0000-0000-000000000001';
END IF;
END IF;
-- Get current tank_alert_enabled and map to schedule status
SELECT value INTO v_enabled
FROM cfg_app_settings
WHERE key = 'tank_alert_enabled';
IF v_enabled IS NOT NULL THEN
v_new_status := CASE WHEN v_enabled = 'true' THEN 'scheduled' ELSE 'cancelled' END;
UPDATE email_schedules
SET status = v_new_status
WHERE id = 'a0000000-0000-0000-0000-000000000001';
END IF;
END $$;
-- 4. Delete deprecated cfg_app_settings entries
DELETE FROM cfg_app_settings WHERE key = 'tank_alert_recipients';
DELETE FROM cfg_app_settings WHERE key = 'tank_alert_enabled';
-- 5. Add RLS policy: prevent deletion of system schedules
-- (Existing policies allow admins to manage system schedules via the
-- "Admins can manage system email schedules" policy from migration 20260208000002.
-- We add a trigger to prevent deletion instead of RLS, since RLS FOR DELETE
-- would need to allow custom schedule deletion while blocking system ones.)
CREATE OR REPLACE FUNCTION prevent_system_schedule_deletion()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.schedule_type = 'system' THEN
RAISE EXCEPTION 'System email schedules cannot be deleted';
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_prevent_system_schedule_deletion ON email_schedules;
CREATE TRIGGER trg_prevent_system_schedule_deletion
BEFORE DELETE ON email_schedules
FOR EACH ROW
EXECUTE FUNCTION prevent_system_schedule_deletion();
-- 6. Create index for quick system schedule lookups
CREATE INDEX IF NOT EXISTS idx_email_schedules_system_type
ON email_schedules (system_type)
WHERE system_type IS NOT NULL;Step 2: Push migration to remote database
Run: pnpm supabase:push Expected: Migration applied successfully
Step 3: Regenerate TypeScript types
Run: pnpm supabase:types Expected: Types regenerated with new schedule_type and system_type columns in email_schedules
Step 4: Commit
git add supabase/migrations/20260210000000_unified_email_schedules.sql src/lib/supabaseTypes.ts
git commit -m "feat: add schedule_type and system_type to email_schedules, migrate tank alert config"Task 2: Update TypeScript Types
Files:
- Modify:
src/features/email-schedules/types.ts
Step 1: Add new types
Add after the EmailScheduleMode type (line 10):
export type EmailScheduleType = "custom" | "system";
export type SystemEmailType = "tank_level_alert" | "calibration_reminder";Step 2: Add fields to EmailSchedule interface
Add to the EmailSchedule interface (after metadata field, around line 52):
scheduleType: EmailScheduleType;
systemType: SystemEmailType | null;Step 3: Commit
git add src/features/email-schedules/types.ts
git commit -m "feat: add scheduleType and systemType to EmailSchedule types"Task 3: Update EmailScheduleService — mapRow and remove method
Files:
- Modify:
src/features/email-schedules/services/emailScheduleService.ts
Step 1: Update imports
Add EmailScheduleType and SystemEmailType to the import from ../types (line 3-12):
import type {
DayOfWeek,
EmailSchedule,
EmailScheduleInput,
EmailScheduleMode,
EmailScheduleStatus,
EmailScheduleType,
EmailScheduleUpdate,
EmailSendResponse,
RecurrenceType,
SystemEmailType,
} from "../types";Step 2: Update mapRow function
Add the new fields to the mapRow function (after metadata mapping, around line 52):
scheduleType: (row.schedule_type as EmailScheduleType) ?? "custom",
systemType: (row.system_type as SystemEmailType) ?? null,Step 3: Add delete protection in remove method
Replace the remove method (lines 334-344) with:
async remove(id: string): Promise<void> {
// Fetch the schedule first to check if it's a system schedule
const { data: schedule, error: fetchError } = await supabase
.from("email_schedules")
.select("schedule_type")
.eq("id", id)
.single();
if (fetchError) {
throw new Error("Schedule not found");
}
if (schedule?.schedule_type === "system") {
throw new Error("System email schedules cannot be deleted");
}
const { error } = await supabase
.from("email_schedules")
.delete()
.eq("id", id);
if (error) {
console.error("Failed to delete email schedule", error);
throw new Error(error.message || "Failed to delete email schedule");
}
},Step 4: Run lint to verify
Run: pnpm lint Expected: No new errors
Step 5: Commit
git add src/features/email-schedules/services/emailScheduleService.ts
git commit -m "feat: update emailScheduleService with scheduleType mapping and delete protection"Task 4: Update EmailSchedulesTable — System Badge and Delete Protection
Files:
- Modify:
src/features/email-schedules/components/EmailSchedulesTable.tsx
Step 1: Add SystemBadge component
Add after the ModeBadge component (after line 53):
function SystemBadge() {
return (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-700">
System
</span>
);
}Step 2: Add System badge to the Email column
In the table row rendering (around line 264-276), add the System badge after the schedule name. Replace:
<td className="py-3">
<div className="font-semibold text-default-800">
{schedule.name}
</div>With:
<td className="py-3">
<div className="flex items-center gap-2">
<span className="font-semibold text-default-800">
{schedule.name}
</span>
{schedule.scheduleType === "system" && <SystemBadge />}
</div>Step 3: Hide delete button for system schedules
In the actions column for pending schedules (around lines 346-382), wrap the delete button with a condition. Replace:
<button
className="btn btn-sm border border-default-200 text-rose-700"
disabled={deletingId === schedule.id}
type="button"
onClick={() => {
onDelete(schedule.id);
}}
>
{deletingId === schedule.id ? "..." : "Delete"}
</button>With:
{schedule.scheduleType !== "system" && (
<button
className="btn btn-sm border border-default-200 text-rose-700"
disabled={deletingId === schedule.id}
type="button"
onClick={() => {
onDelete(schedule.id);
}}
>
{deletingId === schedule.id ? "..." : "Delete"}
</button>
)}Step 4: Run lint to verify
Run: pnpm lint Expected: No new errors
Step 5: Commit
git add src/features/email-schedules/components/EmailSchedulesTable.tsx
git commit -m "feat: add System badge and hide delete button for system schedules"Task 5: Refactor TankAlertManagementService — Use Unified send-email
Files:
- Modify:
src/features/tank-alerts/services/tankAlertManagementService.ts
Step 1: Update the sendAlertEmailNow method
Replace the sendAlertEmailNow method (lines 263-293) with:
/**
* Send alert email immediately via unified send-email Edge Function
* Uses the system email schedule for tank level alerts
*/
static async sendAlertEmailNow(
alertIds: string[],
recipients?: string[]
): Promise<void> {
if (alertIds.length === 0) return;
// The system schedule ID for tank level alerts
const TANK_ALERT_SCHEDULE_ID = "a0000000-0000-0000-0000-000000000001";
// Update scheduled_send_date to today so the Edge Function picks them up
const today = format(new Date(), "yyyy-MM-dd");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabase as any)
.from("tank_level_alerts")
.update({ scheduled_send_date: today, notified: false })
.in("id", alertIds);
if (updateError) {
throw new Error(
`Failed to update alerts for sending: ${updateError.message}`
);
}
// Get the current session to include auth header
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData?.session?.access_token;
if (!accessToken) {
throw new Error("Not authenticated. Please log in again.");
}
// If custom recipients provided, temporarily update the schedule's recipients
// then restore after sending. Otherwise just send with configured recipients.
if (recipients && recipients.length > 0) {
// Call the unified send-email Edge Function with override recipients
// We pass scheduleId to use the system schedule's template
const { error: fnError } = await supabase.functions.invoke("send-email", {
body: {
scheduleId: TANK_ALERT_SCHEDULE_ID,
to: recipients,
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (fnError) {
throw new Error(`Failed to send alert email: ${fnError.message}`);
}
} else {
// Use the schedule's configured recipients
const { error: fnError } = await supabase.functions.invoke("send-email", {
body: {
scheduleId: TANK_ALERT_SCHEDULE_ID,
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (fnError) {
throw new Error(`Failed to send alert email: ${fnError.message}`);
}
}
}Step 2: Run lint to verify
Run: pnpm lint Expected: No new errors
Step 3: Commit
git add src/features/tank-alerts/services/tankAlertManagementService.ts
git commit -m "refactor: use unified send-email Edge Function for tank alerts"Task 6: Update Tank Alert Management UI — Add Email Schedule Link
Files:
- Modify:
src/features/tank-alerts/components/TankAlertManagement.tsx
Step 1: Add import for Link
Add to imports (line 3):
import { Link } from "react-router";Add Mail to the lucide-react import (line 5):
import { Send, Trash2, Clock, CheckCircle, BarChart3, Mail } from "lucide-react";Step 2: Add Email Settings link in the header
In the header section (around line 225-233), add a link to the Email Schedules page. Replace:
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Tank Alert Management
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Monitor and manage water tank level alerts
</p>
</div>With:
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Tank Alert Management
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Monitor and manage water tank level alerts.{" "}
<Link
to="/email-schedules"
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline"
>
<Mail className="w-3.5 h-3.5" />
Configure email settings
</Link>
</p>
</div>Step 3: Run lint to verify
Run: pnpm lint Expected: No new errors
Step 4: Commit
git add src/features/tank-alerts/components/TankAlertManagement.tsx
git commit -m "feat: add Email Schedules link to Tank Alert Management page"Task 7: Update SendEmailModal — Load Recipients from Schedule
Files:
- Modify:
src/features/tank-alerts/components/SendEmailModal.tsx
Step 1: Update the component to load default recipients from the system schedule
Replace the entire file content with:
// src/features/tank-alerts/components/SendEmailModal.tsx
import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
interface SendEmailModalProps {
isOpen: boolean;
alertCount: number;
onClose: () => void;
onConfirm: (recipients: string[]) => void;
}
const TANK_ALERT_SCHEDULE_ID = "a0000000-0000-0000-0000-000000000001";
export function SendEmailModal({
isOpen,
alertCount,
onClose,
onConfirm,
}: SendEmailModalProps) {
const [recipients, setRecipients] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Load default recipients from the system email schedule
useEffect(() => {
if (!isOpen) return;
setError(null);
setLoading(true);
const loadRecipients = async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error: fetchError } = await (supabase as any)
.from("email_schedules")
.select("to_recipients")
.eq("id", TANK_ALERT_SCHEDULE_ID)
.single();
if (fetchError) {
console.error("Failed to load schedule recipients:", fetchError);
setRecipients("no-reply@dustac.com.au");
return;
}
const toRecipients = (data?.to_recipients as string[]) ?? [];
setRecipients(
toRecipients.length > 0
? toRecipients.join(", ")
: "no-reply@dustac.com.au"
);
} catch {
setRecipients("no-reply@dustac.com.au");
} finally {
setLoading(false);
}
};
void loadRecipients();
}, [isOpen]);
if (!isOpen) return null;
const validateEmails = (emailStr: string): string[] | null => {
const emails = emailStr
.split(/[,;\n]/)
.map((e) => e.trim())
.filter((e) => e.length > 0);
if (emails.length === 0) {
setError("Please enter at least one email address");
return null;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const invalidEmails = emails.filter((e) => !emailRegex.test(e));
if (invalidEmails.length > 0) {
setError(`Invalid email address: ${invalidEmails.join(", ")}`);
return null;
}
return emails;
};
const handleConfirm = () => {
const validEmails = validateEmails(recipients);
if (validEmails) {
onConfirm(validEmails);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
role="button"
tabIndex={0}
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<div className="relative bg-white dark:bg-slate-800 rounded-xl p-6 shadow-xl w-[480px] max-w-[90vw]">
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
Send Alert Email
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{alertCount === 1
? "Send 1 tank alert email to the following recipients:"
: `Send ${alertCount} tank alert emails to the following recipients:`}
</p>
<div className="mb-4">
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
htmlFor="recipients"
>
Recipients
</label>
<textarea
id="recipients"
placeholder="Enter email addresses (separated by comma, semicolon, or new line)"
rows={3}
value={recipients}
disabled={loading}
className={`w-full px-3 py-2 border rounded-lg bg-white dark:bg-slate-700
text-gray-900 dark:text-white resize-none
${error ? "border-red-500" : "border-gray-300 dark:border-slate-600"}
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50`}
onChange={(e) => {
setRecipients(e.target.value);
setError(null);
}}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
<p className="mt-1 text-xs text-gray-400">
Default recipients loaded from{" "}
<a
href="/email-schedules"
className="text-blue-500 hover:underline"
>
Email Schedules
</a>
. Separate multiple emails with comma, semicolon, or new line.
</p>
</div>
<div className="flex justify-end gap-2">
<button
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100
dark:hover:bg-slate-700 rounded-lg transition-colors"
onClick={onClose}
>
Cancel
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
disabled={loading}
onClick={handleConfirm}
>
Send Email
</button>
</div>
</div>
</div>
);
}Step 2: Run lint to verify
Run: pnpm lint Expected: No new errors
Step 3: Commit
git add src/features/tank-alerts/components/SendEmailModal.tsx
git commit -m "refactor: load SendEmailModal recipients from system email schedule"Task 8: Update Edge Function EmailScheduleRow Type
Files:
- Modify:
supabase/functions/send-email/index.ts
Step 1: Add new fields to EmailScheduleRow type
In the EmailScheduleRow type (lines 50-73), add the new fields after from_sender_id:
// System schedule fields
schedule_type: "custom" | "system";
system_type: string | null;Step 2: Commit
git add supabase/functions/send-email/index.ts
git commit -m "feat: add schedule_type and system_type to Edge Function EmailScheduleRow type"Task 9: Remove send-tank-level-alert Edge Function
Files:
- Delete:
supabase/functions/send-tank-level-alert/index.ts - Modify:
package.json(remove deploy script if exists)
Step 1: Check for deployment scripts referencing send-tank-level-alert
Run: grep -rn "send-tank-level-alert" package.json scripts/ .github/ 2>/dev/null
Remove any references found.
Step 2: Delete the Edge Function directory
Run: rm -rf supabase/functions/send-tank-level-alert
Step 3: Verify no other code references the old function
Run: grep -rn "send-tank-level-alert" src/ supabase/ --include="*.ts" --include="*.tsx" --include="*.json"
Expected: No results (the reference in tankAlertManagementService.ts was already updated in Task 5)
Step 4: Commit
git add -A
git commit -m "chore: remove deprecated send-tank-level-alert Edge Function"Task 10: Verify and Test End-to-End
Step 1: Run TypeScript check
Run: pnpm build:check Expected: No TypeScript errors
Step 2: Run linter
Run: pnpm lint Expected: No new lint errors (max 500 warnings threshold)
Step 3: Run unit tests
Run: pnpm test:unit Expected: All existing tests pass
Step 4: Manual verification checklist
Email Schedules page:
- [ ] "Tank Level Low Alert" schedule appears in the list
- [ ] It shows a blue "System" badge next to the name
- [ ] Clicking it opens the edit form with all fields editable
- [ ] No "Delete" button appears for the system schedule
- [ ] "Send Now" button works and triggers the unified send-email function
- [ ] Custom schedules still have the "Delete" button
- [ ] Creating new schedules still works (type defaults to "custom")
Tank Alert Management page:
- [ ] "Configure email settings" link appears in the header
- [ ] Clicking the link navigates to Email Schedules page
- [ ] "Send Now" on individual alerts opens the SendEmailModal
- [ ] SendEmailModal loads recipients from the system email schedule
- [ ] Sending works via the unified send-email Edge Function
- [ ] Bulk send still works
- [ ] All other operations (mark sent, mark resolved, delete, change date) unchanged
Cron processing:
- [ ] The cron call to
send-emailwithaction: "process_due"picks up the tank alert system schedule - [ ] When
next_send_atis due, it fetches tank alert data and sends the email - [ ] Alerts are marked as
notified = trueafter sending
- [ ] The cron call to
Step 5: Final commit
git add -A
git commit -m "feat: unified email schedules - complete integration"Summary of All Changes
| File | Action | Description |
|---|---|---|
supabase/migrations/20260210000000_unified_email_schedules.sql | Create | Add columns, migrate config, delete old settings, add trigger |
src/lib/supabaseTypes.ts | Regenerate | Auto-generated types with new columns |
src/features/email-schedules/types.ts | Modify | Add EmailScheduleType, SystemEmailType, new fields |
src/features/email-schedules/services/emailScheduleService.ts | Modify | Update mapRow, add delete protection in remove |
src/features/email-schedules/components/EmailSchedulesTable.tsx | Modify | Add SystemBadge, hide delete for system schedules |
src/features/tank-alerts/services/tankAlertManagementService.ts | Modify | Use unified send-email instead of send-tank-level-alert |
src/features/tank-alerts/components/TankAlertManagement.tsx | Modify | Add Email Schedules link |
src/features/tank-alerts/components/SendEmailModal.tsx | Modify | Load recipients from system schedule |
supabase/functions/send-email/index.ts | Modify | Add new fields to EmailScheduleRow type |
supabase/functions/send-tank-level-alert/ | Delete | Deprecated, logic already in send-email |