{{ theme.skipToContentLabel || 'Skip to content' }}

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

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

bash
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):

typescript
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):

typescript
  scheduleType: EmailScheduleType;
  systemType: SystemEmailType | null;

Step 3: Commit

bash
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):

typescript
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):

typescript
    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:

typescript
  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

bash
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):

typescript
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:

typescript
                      <td className="py-3">
                        <div className="font-semibold text-default-800">
                          {schedule.name}
                        </div>

With:

typescript
                      <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:

typescript
                              <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:

typescript
                              {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

bash
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:

typescript
  /**
   * 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

bash
git add src/features/tank-alerts/services/tankAlertManagementService.ts
git commit -m "refactor: use unified send-email Edge Function for tank alerts"

Files:

  • Modify: src/features/tank-alerts/components/TankAlertManagement.tsx

Step 1: Add import for Link

Add to imports (line 3):

typescript
import { Link } from "react-router";

Add Mail to the lucide-react import (line 5):

typescript
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:

typescript
        <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:

typescript
        <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

bash
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:

typescript
// 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

bash
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:

typescript
  // System schedule fields
  schedule_type: "custom" | "system";
  system_type: string | null;

Step 2: Commit

bash
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

bash
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

  1. 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")
  2. 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
  3. Cron processing:

    • [ ] The cron call to send-email with action: "process_due" picks up the tank alert system schedule
    • [ ] When next_send_at is due, it fetches tank alert data and sends the email
    • [ ] Alerts are marked as notified = true after sending

Step 5: Final commit

bash
git add -A
git commit -m "feat: unified email schedules - complete integration"

Summary of All Changes

FileActionDescription
supabase/migrations/20260210000000_unified_email_schedules.sqlCreateAdd columns, migrate config, delete old settings, add trigger
src/lib/supabaseTypes.tsRegenerateAuto-generated types with new columns
src/features/email-schedules/types.tsModifyAdd EmailScheduleType, SystemEmailType, new fields
src/features/email-schedules/services/emailScheduleService.tsModifyUpdate mapRow, add delete protection in remove
src/features/email-schedules/components/EmailSchedulesTable.tsxModifyAdd SystemBadge, hide delete for system schedules
src/features/tank-alerts/services/tankAlertManagementService.tsModifyUse unified send-email instead of send-tank-level-alert
src/features/tank-alerts/components/TankAlertManagement.tsxModifyAdd Email Schedules link
src/features/tank-alerts/components/SendEmailModal.tsxModifyLoad recipients from system schedule
supabase/functions/send-email/index.tsModifyAdd new fields to EmailScheduleRow type
supabase/functions/send-tank-level-alert/DeleteDeprecated, logic already in send-email