Admin composes; the system delivers. Targeted broadcast to all parents, one branch, or one section. Email now, WhatsApp later.
Circulars are how schools talk to parents at scale — fee due reminders, school closure notices, event invitations, exam schedules. This module is small but high-value. Plan ~6 hours.
By the end:
Branch admin composes a circular with rich text
Audience picker: All parents · One grade · One section · One student's parent
Preview shows the recipient count
Send queues an edge function that emails everyone
Parents see all circulars they received in /parent/circulars
create table public.circulars ( id uuid primary key default gen_random_uuid(), branch_id uuid not null references public.branches(id), subject text not null, body_md text not null, -- Markdown audience text not null check (audience in ('all', 'grade', 'section', 'parent')), grade text, -- For audience = 'grade' section_id uuid references public.class_sections(id), -- For audience = 'section' recipient_parent_id uuid references public.profiles(id), -- For audience = 'parent' status text not null default 'draft' check (status in ('draft', 'queued', 'sending', 'sent', 'failed')), sent_at timestamptz, created_by uuid not null references public.profiles(id), created_at timestamptz not null default now());create table public.circular_recipients ( id uuid primary key default gen_random_uuid(), circular_id uuid not null references public.circulars(id) on delete cascade, parent_id uuid not null references public.profiles(id), delivery_status text not null default 'pending' check (delivery_status in ('pending', 'sent', 'opened', 'failed')), sent_at timestamptz, opened_at timestamptz, error_msg text, unique (circular_id, parent_id));-- RLS:-- Admins (super, branch) can insert circulars in their branch-- Parents can see only rows in circular_recipients where parent_id = auth.uid()-- Joined query: parents see circulars they're a recipient of
The split circulars → circular_recipients is intentional. Once you've sent a circular, you have an audit log of who received it, when, and (if you implement open tracking) whether they opened it.
┌──────────────────────────────────────────┐│ Subject: [School closed on 14 May ] ││ ││ Audience: ││ ( ) All parents in Andheri (482) ││ (•) Specific grade [Grade 5 ▼] (98) ││ ( ) Specific section [5A ▼] (26) ││ ( ) Just one parent [search...] ││ ││ Message: ││ ┌────────────────────────────────────┐ ││ │ Dear parents, │ ││ │ │ ││ │ Due to the local elections, │ ││ │ school will remain closed on │ ││ │ Thursday, 14 May 2026. │ ││ │ ... │ ││ └────────────────────────────────────┘ ││ ││ [Save draft] [Preview] [Send to 98] │└──────────────────────────────────────────┘
The recipient count next to each audience option is live — it requeries as the admin changes selections. Use a Supabase RPC to count:
create or replace function public.count_circular_recipients( p_branch_id uuid, p_audience text, p_grade text default null, p_section_id uuid default null, p_parent_id uuid default null)returns intlanguage sqlstablesecurity invokeras $$ select count(distinct s.parent_id)::int from public.students s where s.deleted_at is null and s.branch_id = p_branch_id and case when p_audience = 'all' then true when p_audience = 'grade' then s.section_id in (select id from public.class_sections where grade = p_grade) when p_audience = 'section' then s.section_id = p_section_id when p_audience = 'parent' then s.parent_id = p_parent_id end;$$;
The count is fast (single query, indexed). The compose form calls it via TanStack Query with staleTime: 30_000.
/parent/circulars — a list of circulars the parent has received:
This year (12)Mon 12 May · School closed on 14 May [open]Fri 9 May · Half-yearly exam schedule released [open]Mon 5 May · Grade 5A annual day rehearsal — Tue [open]...
You're now able to send announcements to thousands of parents in seconds. The next module is Branches — multi-tenant infrastructure. With this in place, the system is genuinely school-grade.