1. The data model
Lays down the contacts, sequences, steps, enrollments, and email job tables the whole engine runs on.
Add a Postgres migration that creates the schema for a follow-up email engine. Use these tables:
- contacts: id (uuid pk), first_name, last_name, email, phone, service_type, address, notes, source (check in 'manual','csv','sms_intake','api', default 'manual'), created_at.
- sequences: id (uuid pk), slug, name, is_default boolean, enabled boolean default true. Enforce only one is_default = true at a time.
- sequence_steps: id (uuid pk), sequence_id fk, position int, channel (check in 'email','sms', default 'email'), delay_hours int (cumulative hours from enrollment start), subject text, body text, enabled boolean default true. Unique on (sequence_id, position).
- contact_enrollments: id (uuid pk), contact_id fk, sequence_id fk, current_position int default 0, status (check in 'active','paused','completed','canceled','opted_out', default 'active'), next_send_at timestamptz, context_jsonb jsonb (frozen copy of the contact's merge-tag values at enrollment time), started_at, completed_at, canceled_at. Add a partial unique index so a contact can only have one active enrollment per sequence.
- email_jobs: id (uuid pk), job_type text, to_email text, subject text, body text, status (check in 'pending','sending','sent','failed', default 'pending'), attempt_count int default 0, related_enrollment_id fk nullable, related_step_id fk nullable, created_at, sent_at.
- send_log: id (uuid pk), email_job_id fk, to_email, provider_message_id text, sent_at, created_at.
Add sensible indexes: enrollments by (status, next_send_at) for the worker scan, email_jobs by (status, created_at). Then seed one default sequence "post_appointment" with 4 steps at delay_hours 0, 24, 72, 96 - a thank-you, a check-in, a review ask, and a referral ask. Use {{first_name}}, {{business_name}}, and {{review_link}} merge tags in the bodies.