🗺️

Visual ERD

Entity relationships and cardinality for all 12 tables.

users 🔑 id email name role password_hash created_at · updated_at children 🔑 id 🔗 parent_id full_name date_of_birth · gender school · grade tshirt_size allergies (jsonb) medications (jsonb) dietary_restrictions special_needs · prefs emergency_contacts 🔑 id 🔗 child_id name · relationship phone · email sort_order insurance_info 🔑 id 🔗 child_id provider · policy_number group_number authorized_pickups 🔑 id 🔗 child_id name · relationship · phone documents 🔑 id 🔗 child_id name · file_key · file_type category · expires_at uploaded_at camps 🔑 id 🔗 director_id name · description city · state · zip lat · lng (geospatial) camp_type · schedule_type age_min · age_max price_per_week · ratio scholarship · inclusive created_at · updated_at sessions 🔑 id 🔗 camp_id start_date · end_date capacity · spots_remaining status applications 🔑 id 🔗 child_id 🔗 session_id status submitted_at · responded_at waitlist_position camp_specific_responses (jsonb) reviews 🔑 id 🔗 parent_id · camp_id 🔗 session_id rating · text verified created_at saved_camps 🔑 id 🔗 parent_id · camp_id created_at form_mappings 🔑 id 🔗 camp_id canonical_field · label required User/Child domain Camp domain Child supporting
📋

Entity Overview

All 12 tables, their purpose, and estimated steady-state row counts.

Table Domain Purpose Est. Rows (2yr)
users Auth Parents and camp directors sharing one auth table; role discriminates. 500 K
children Profile vault Normalized child profile. JSONB for medical lists avoids sparse nullable columns. 700 K
emergency_contacts Child support Ordered list of emergency contacts per child. 1.8 M
insurance_info Child support One insurance record per child (1-1 in practice). 650 K
authorized_pickups Child support Adults permitted to collect a child; shared across all their camp enrollments. 1.2 M
documents Child support S3-backed file references. file_key is the S3 object key; PII never stored twice. 3 M
camps Supply Camp listings. Lat/lng for geospatial proximity search. 25 K
sessions Supply Specific week-long (or multi-week) instances of a camp with capacity tracking. 150 K
applications Transactions Core transaction record. JSONB stores camp-specific Q&A answers keyed by form_mappings.id. 2 M
reviews Social Verified (attended the session) and unverified parent reviews. 800 K
saved_camps Social Parent wishlist / bookmarks. Unique constraint prevents duplicates. 1.5 M
form_mappings Integration The "Common App" magic: maps canonical profile fields to each camp's label vocabulary. 400 K
🧱

DDL — Users & Auth

Foundation table shared by parents and camp directors.

01_users.sql SQL
-- Enable pgcrypto for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TYPE user_role AS ENUM ('parent', 'director', 'admin');

CREATE TABLE users (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  email           TEXT        NOT NULL UNIQUE,
  name            TEXT        NOT NULL,
  role            user_role   NOT NULL DEFAULT 'parent',
  password_hash   TEXT        NOT NULL,
  email_verified  BOOLEAN     NOT NULL DEFAULT false,
  last_login_at   TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Partial index: fast lookup of verified parents only
CREATE INDEX idx_users_email     ON users (email);
CREATE INDEX idx_users_role      ON users (role);
CREATE INDEX idx_users_verified  ON users (email_verified) WHERE email_verified = true;

-- Auto-update updated_at on any row change
CREATE OR REPLACE FUNCTION set_updated_at()
  RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END; $$;

CREATE TRIGGER trg_users_updated_at
  BEFORE UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
👤

DDL — Children

The core profile vault — every field a camp will ever ask for.

💡
JSONB for medical lists allergies and medications are stored as JSONB arrays rather than junction tables. This avoids a schema migration every time a new allergen category emerges and keeps the full medical snapshot together for a single-row read during application pre-fill. GIN indexes make containment queries fast.
02_children.sql SQL
CREATE TYPE gender_type   AS ENUM ('male', 'female', 'nonbinary', 'prefer_not_to_say');
CREATE TYPE tshirt_size   AS ENUM ('YS', 'YM', 'YL', 'AS', 'AM', 'AL', 'AXL');

CREATE TABLE children (
  id                    UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
  parent_id             UUID           NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  full_name             TEXT           NOT NULL,
  date_of_birth         DATE           NOT NULL,
  gender                gender_type,
  school                TEXT,
  grade                 SMALLINT       CHECK (grade BETWEEN -1 AND 12), -- -1 = PreK
  tshirt_size           tshirt_size,
  allergies             JSONB          NOT NULL DEFAULT '[]',
  medications           JSONB          NOT NULL DEFAULT '[]',
  dietary_restrictions  TEXT,
  special_needs_notes   TEXT,
  preferences_notes     TEXT,
  created_at            TIMESTAMPTZ    NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ    NOT NULL DEFAULT now()
);

CREATE INDEX idx_children_parent     ON children (parent_id);
CREATE INDEX idx_children_dob        ON children (date_of_birth);

-- GIN indexes for JSONB containment queries
-- e.g. allergies @> '[{"type": "peanut"}]'
CREATE INDEX idx_children_allergies    ON children USING GIN (allergies);
CREATE INDEX idx_children_medications  ON children USING GIN (medications);

CREATE TRIGGER trg_children_updated_at
  BEFORE UPDATE ON children
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

JSONB Schema Contracts

allergies / medications shape JSON
// allergies: array of allergy objects
[
  { "type": "peanut", "severity": "anaphylactic", "notes": "Carries EpiPen" },
  { "type": "bee_sting", "severity": "mild", "notes": "" }
]

// medications: array of medication objects
[
  {
    "name": "Adderall XR",
    "dosage": "10mg",
    "frequency": "daily_morning",
    "prescriber": "Dr. Nguyen",
    "notes": "Do not administer after 2pm"
  }
]
📁

DDL — Child Supporting Tables

Emergency contacts, insurance, authorized pickups, and documents all cascade-delete with the child record.

03_child_supporting.sql SQL
-- ── emergency_contacts ─────────────────────────────────────────────
CREATE TABLE emergency_contacts (
  id            UUID     PRIMARY KEY DEFAULT gen_random_uuid(),
  child_id      UUID     NOT NULL REFERENCES children(id) ON DELETE CASCADE,
  name          TEXT     NOT NULL,
  relationship  TEXT     NOT NULL,
  phone         TEXT     NOT NULL,
  email         TEXT,
  sort_order    SMALLINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_ec_child ON emergency_contacts (child_id, sort_order);

-- ── insurance_info ──────────────────────────────────────────────────
CREATE TABLE insurance_info (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  child_id       UUID NOT NULL UNIQUE REFERENCES children(id) ON DELETE CASCADE,
  provider       TEXT NOT NULL,
  policy_number  TEXT NOT NULL,
  group_number   TEXT
);

-- ── authorized_pickups ──────────────────────────────────────────────
CREATE TABLE authorized_pickups (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  child_id      UUID NOT NULL REFERENCES children(id) ON DELETE CASCADE,
  name          TEXT NOT NULL,
  relationship  TEXT NOT NULL,
  phone         TEXT NOT NULL
);

CREATE INDEX idx_pickups_child ON authorized_pickups (child_id);

-- ── documents ───────────────────────────────────────────────────────
CREATE TYPE doc_category AS ENUM (
  'physical_exam', 'immunization', 'insurance_card', 'photo_id', 'other'
);

CREATE TABLE documents (
  id          UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
  child_id    UUID           NOT NULL REFERENCES children(id) ON DELETE CASCADE,
  name        TEXT           NOT NULL,
  file_key    TEXT           NOT NULL UNIQUE, -- S3 object key
  file_type   TEXT           NOT NULL,         -- MIME type
  category    doc_category   NOT NULL,
  expires_at  DATE,
  uploaded_at TIMESTAMPTZ    NOT NULL DEFAULT now()
);

CREATE INDEX idx_docs_child    ON documents (child_id);
CREATE INDEX idx_docs_category ON documents (child_id, category);
CREATE INDEX idx_docs_expires  ON documents (expires_at) WHERE expires_at IS NOT NULL;
🏕️

DDL — Camps & Sessions

Supply side of the marketplace with geospatial coordinates and capacity management.

04_camps_sessions.sql SQL
CREATE TYPE camp_type      AS ENUM ('day', 'overnight', 'hybrid');
CREATE TYPE schedule_type  AS ENUM ('full_day', 'half_am', 'half_pm');
CREATE TYPE session_status AS ENUM ('open', 'full', 'waitlist', 'cancelled');

CREATE TABLE camps (
  id                       UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
  director_id              UUID           NOT NULL REFERENCES users(id),
  name                     TEXT           NOT NULL,
  description              TEXT,
  location                 TEXT,          -- street address display string
  city                     TEXT           NOT NULL,
  state                    CHAR(2)        NOT NULL,
  zip                      TEXT           NOT NULL,
  latitude                 NUMERIC(9,6)  NOT NULL,
  longitude                NUMERIC(9,6)  NOT NULL,
  camp_type                camp_type      NOT NULL DEFAULT 'day',
  age_min                  SMALLINT       NOT NULL,
  age_max                  SMALLINT       NOT NULL,
  schedule_type            schedule_type  NOT NULL DEFAULT 'full_day',
  price_per_week           NUMERIC(8,2),
  counselor_ratio          TEXT,          -- e.g. '1:8'
  years_operating          SMALLINT,
  scholarship_available    BOOLEAN        NOT NULL DEFAULT false,
  inclusive_accommodations BOOLEAN        NOT NULL DEFAULT false,
  search_vector            TSVECTOR,      -- full-text search
  created_at               TIMESTAMPTZ    NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ    NOT NULL DEFAULT now(),

  CONSTRAINT chk_age_range CHECK (age_min <= age_max),
  CONSTRAINT chk_lat       CHECK (latitude  BETWEEN -90  AND 90),
  CONSTRAINT chk_lng       CHECK (longitude BETWEEN -180 AND 180)
);

CREATE INDEX idx_camps_director     ON camps (director_id);
CREATE INDEX idx_camps_state        ON camps (state);
CREATE INDEX idx_camps_age          ON camps (age_min, age_max);
CREATE INDEX idx_camps_geo          ON camps (latitude, longitude);
CREATE INDEX idx_camps_fts          ON camps USING GIN (search_vector);
CREATE INDEX idx_camps_scholarship  ON camps (scholarship_available) WHERE scholarship_available = true;
CREATE INDEX idx_camps_inclusive    ON camps (inclusive_accommodations) WHERE inclusive_accommodations = true;

-- Auto-generate tsvector from name + description + city
CREATE OR REPLACE FUNCTION camps_search_vector_update()
  RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(NEW.city, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(NEW.description, '')), 'C');
  RETURN NEW;
END; $$;

CREATE TRIGGER trg_camps_fts
  BEFORE INSERT OR UPDATE ON camps
  FOR EACH ROW EXECUTE FUNCTION camps_search_vector_update();

CREATE TRIGGER trg_camps_updated_at
  BEFORE UPDATE ON camps
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- ── sessions ────────────────────────────────────────────────────────
CREATE TABLE sessions (
  id              UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
  camp_id         UUID           NOT NULL REFERENCES camps(id) ON DELETE CASCADE,
  start_date      DATE           NOT NULL,
  end_date        DATE           NOT NULL,
  capacity        SMALLINT       NOT NULL CHECK (capacity > 0),
  spots_remaining SMALLINT       NOT NULL CHECK (spots_remaining >= 0),
  status          session_status NOT NULL DEFAULT 'open',

  CONSTRAINT chk_dates    CHECK (end_date > start_date),
  CONSTRAINT chk_spots   CHECK (spots_remaining <= capacity)
);

CREATE INDEX idx_sessions_camp    ON sessions (camp_id);
CREATE INDEX idx_sessions_dates   ON sessions (start_date, end_date);
CREATE INDEX idx_sessions_open    ON sessions (camp_id, status) WHERE status = 'open';
CREATE INDEX idx_sessions_future  ON sessions (start_date) WHERE start_date > now();
📝

DDL — Applications

The central transaction record connecting children to sessions, carrying camp-specific answers in JSONB.

05_applications.sql SQL
CREATE TYPE application_status AS ENUM (
  'draft', 'submitted', 'under_review', 'accepted',
  'waitlisted', 'declined', 'withdrawn'
);

CREATE TABLE applications (
  id                       UUID               PRIMARY KEY DEFAULT gen_random_uuid(),
  child_id                 UUID               NOT NULL REFERENCES children(id),
  session_id               UUID               NOT NULL REFERENCES sessions(id),
  status                   application_status NOT NULL DEFAULT 'draft',
  submitted_at             TIMESTAMPTZ,
  responded_at             TIMESTAMPTZ,
  waitlist_position        SMALLINT,
  -- Keys are form_mappings.id; values are the parent's answers
  camp_specific_responses  JSONB              NOT NULL DEFAULT '{}',
  created_at               TIMESTAMPTZ        NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ        NOT NULL DEFAULT now(),

  -- One child can only have one active application per session
  CONSTRAINT uq_child_session UNIQUE (child_id, session_id)
);

CREATE INDEX idx_apps_child       ON applications (child_id);
CREATE INDEX idx_apps_session     ON applications (session_id);
CREATE INDEX idx_apps_status      ON applications (status);
CREATE INDEX idx_apps_submitted   ON applications (submitted_at) WHERE submitted_at IS NOT NULL;
CREATE INDEX idx_apps_responses   ON applications USING GIN (camp_specific_responses);
CREATE INDEX idx_apps_waitlist    ON applications (session_id, waitlist_position)
  WHERE status = 'waitlisted';

CREATE TRIGGER trg_apps_updated_at
  BEFORE UPDATE ON applications
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- Decrement spots_remaining when an application is accepted
CREATE OR REPLACE FUNCTION manage_session_capacity()
  RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.status = 'accepted' AND (OLD.status IS NULL OR OLD.status != 'accepted') THEN
    UPDATE sessions
    SET    spots_remaining = spots_remaining - 1,
           status = CASE WHEN spots_remaining - 1 = 0 THEN 'full'::session_status
                         ELSE status END
    WHERE  id = NEW.session_id;
  ELSIF OLD.status = 'accepted' AND NEW.status = 'withdrawn' THEN
    UPDATE sessions
    SET    spots_remaining = spots_remaining + 1,
           status = 'open'
    WHERE  id = NEW.session_id;
  END IF;
  RETURN NEW;
END; $$;

CREATE TRIGGER trg_capacity
  AFTER UPDATE ON applications
  FOR EACH ROW EXECUTE FUNCTION manage_session_capacity();

DDL — Reviews & Saved Camps

06_social.sql SQL
CREATE TABLE reviews (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  parent_id   UUID        NOT NULL REFERENCES users(id),
  camp_id     UUID        NOT NULL REFERENCES camps(id) ON DELETE CASCADE,
  session_id  UUID        REFERENCES sessions(id),
  rating      SMALLINT    NOT NULL CHECK (rating BETWEEN 1 AND 5),
  text        TEXT,
  verified    BOOLEAN     NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- One review per parent per camp session
  CONSTRAINT uq_review UNIQUE (parent_id, camp_id, session_id)
);

CREATE INDEX idx_reviews_camp      ON reviews (camp_id);
CREATE INDEX idx_reviews_verified  ON reviews (camp_id, verified) WHERE verified = true;

-- ── saved_camps ─────────────────────────────────────────────────────
CREATE TABLE saved_camps (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  parent_id   UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  camp_id     UUID        NOT NULL REFERENCES camps(id) ON DELETE CASCADE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT uq_saved UNIQUE (parent_id, camp_id)
);

CREATE INDEX idx_saved_parent ON saved_camps (parent_id);
🔄

DDL — Form Mappings

The "Common App" engine — maps the canonical child profile to each camp's own field labels and requirements.

07_form_mappings.sql SQL
CREATE TABLE form_mappings (
  id                 UUID    PRIMARY KEY DEFAULT gen_random_uuid(),
  camp_id            UUID    NOT NULL REFERENCES camps(id) ON DELETE CASCADE,
  -- Dot-path into the child/related profile, e.g.:
  -- 'child.full_name', 'child.allergies', 'emergency_contacts.0.phone'
  canonical_field    TEXT    NOT NULL,
  camp_field_label   TEXT    NOT NULL,
  required           BOOLEAN NOT NULL DEFAULT true,
  sort_order         SMALLINT NOT NULL DEFAULT 0,
  help_text          TEXT,

  CONSTRAINT uq_camp_canonical UNIQUE (camp_id, canonical_field)
);

CREATE INDEX idx_fm_camp             ON form_mappings (camp_id, sort_order);
CREATE INDEX idx_fm_canonical        ON form_mappings (canonical_field);

Index Strategy

Index decisions, types, and their read/write trade-offs at scale.

Index Type Rationale Trade-off
idx_children_allergies GIN Containment queries: camps filtering by allergen-safe children ~3x write overhead; justifiable given read dominance
idx_camps_fts GIN (tsvector) Full-text search across camp name, city, description Maintained by trigger; add pg_trgm for prefix search
idx_camps_geo B-tree (lat, lng) Bounding box pre-filter before Haversine rerank Not a true spatial index; sufficient without PostGIS
idx_sessions_open Partial B-tree Only indexes status = 'open' rows — tiny at peak Must rebuild when sessions close en masse (June–August)
idx_apps_waitlist Partial B-tree Fast waitlist position lookup without scanning all apps Small index; negligible overhead
idx_apps_responses GIN (jsonb) Director queries filtering by specific form answers Large JSONB values inflate index; monitor pg_relation_size
idx_reviews_verified Partial B-tree Camp detail pages only show verified reviews by default ~60% of reviews are verified — partial index saves ~40%
idx_docs_expires Partial B-tree Background job: remind parents of expiring documents NULL expires_at rows excluded from index
📍

Geospatial Search

Two-tier approach: bounding box pre-filter on B-tree, then Haversine rerank. PostGIS upgrade path included.

📐
Why not PostGIS immediately? At 25,000 camps the bounding box + Haversine approach on plain NUMERIC columns performs well under load and avoids the PostGIS extension dependency during initial deployment. The PostGIS migration path below is a one-day upgrade when geofencing or polygon search is needed.

Current: Haversine on NUMERIC columns

geo_search_haversine.sql SQL
-- Camps within 10 miles of lat/lng point (zip 78704, Austin TX)
-- Step 1: bounding box (uses idx_camps_geo B-tree, very fast)
-- Step 2: Haversine rerank for accurate distance

WITH origin AS (
  SELECT
    30.2500  AS lat,
    -97.7500 AS lng,
    10.0     AS radius_miles
),
bbox AS (
  SELECT
    lat - (radius_miles / 69.0)  AS lat_min,
    lat + (radius_miles / 69.0)  AS lat_max,
    lng - (radius_miles / cos(radians(lat)) / 69.0) AS lng_min,
    lng + (radius_miles / cos(radians(lat)) / 69.0) AS lng_max,
    lat, lng, radius_miles
  FROM origin
)
SELECT
  c.id,
  c.name,
  c.city,
  c.price_per_week,
  3959 * acos(
    cos(radians(b.lat)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians(b.lng)) +
    sin(radians(b.lat)) * sin(radians(c.latitude))
  ) AS distance_miles
FROM       camps c
CROSS JOIN bbox  b
WHERE
  c.latitude  BETWEEN b.lat_min AND b.lat_max
  AND c.longitude BETWEEN b.lng_min AND b.lng_max
HAVING
  3959 * acos(
    cos(radians(b.lat)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians(b.lng)) +
    sin(radians(b.lat)) * sin(radians(c.latitude))
  ) <= b.radius_miles
ORDER BY distance_miles
LIMIT 50;

PostGIS Upgrade Path

geo_postgis_migration.sql SQL
CREATE EXTENSION IF NOT EXISTS postgis;

-- Add geography column (uses WGS-84 spheroid — accurate to meters)
ALTER TABLE camps
  ADD COLUMN geog GEOGRAPHY(Point, 4326);

-- Back-fill from existing lat/lng
UPDATE camps
SET    geog = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326);

-- GIST spatial index (replaces lat/lng B-tree for proximity)
CREATE INDEX idx_camps_geog ON camps USING GIST (geog);

-- PostGIS proximity query (cleaner, uses index natively)
SELECT
  c.id, c.name,
  ST_Distance(c.geog, ST_MakePoint(-97.75, 30.25)::geography) / 1609.34 AS miles
FROM  camps c
WHERE ST_DWithin(c.geog, ST_MakePoint(-97.75, 30.25)::geography, 16093.4) -- 10 miles in meters
ORDER BY miles
LIMIT  50;
🔐

Row-Level Security

Postgres RLS enforces access boundaries at the database layer — independent of application code.

👨‍👩‍👧 Parent
  • SELECT/INSERT/UPDATE/DELETE own children
  • SELECT/INSERT/UPDATE/DELETE own children's supporting records
  • SELECT all camps and open sessions
  • INSERT/SELECT own applications
  • INSERT/SELECT/DELETE own saved_camps
  • INSERT/SELECT own reviews
🏕️ Director
  • SELECT/UPDATE/DELETE own camps
  • INSERT/SELECT/UPDATE/DELETE own sessions
  • SELECT applications for own sessions
  • UPDATE application status for own sessions
  • SELECT/INSERT/UPDATE/DELETE own form_mappings
  • SELECT (aggregated) reviews for own camps
🛠️ Admin
  • Bypass all RLS via SET ROLE
  • Full access all tables
  • GDPR/COPPA deletion workflows
08_rls_policies.sql SQL
-- Enable RLS on all user-data tables
ALTER TABLE children             ENABLE ROW LEVEL SECURITY;
ALTER TABLE emergency_contacts   ENABLE ROW LEVEL SECURITY;
ALTER TABLE insurance_info       ENABLE ROW LEVEL SECURITY;
ALTER TABLE authorized_pickups   ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents            ENABLE ROW LEVEL SECURITY;
ALTER TABLE camps                ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions             ENABLE ROW LEVEL SECURITY;
ALTER TABLE applications         ENABLE ROW LEVEL SECURITY;
ALTER TABLE saved_camps          ENABLE ROW LEVEL SECURITY;
ALTER TABLE reviews              ENABLE ROW LEVEL SECURITY;
ALTER TABLE form_mappings        ENABLE ROW LEVEL SECURITY;

-- ── CHILDREN: parents own their children ──────────────────────────
CREATE POLICY children_parent_all ON children
  USING      (parent_id = current_setting('app.user_id')::uuid)
  WITH CHECK (parent_id = current_setting('app.user_id')::uuid);

-- ── EMERGENCY CONTACTS: visible if parent owns the child ──────────
CREATE POLICY ec_parent_all ON emergency_contacts
  USING (
    EXISTS (
      SELECT 1 FROM children
      WHERE  id = emergency_contacts.child_id
      AND    parent_id = current_setting('app.user_id')::uuid
    )
  );

-- Same pattern for insurance_info, authorized_pickups, documents
-- (omitted for brevity — replace table name and child FK column)

-- ── CAMPS: public read, director write ────────────────────────────
CREATE POLICY camps_public_read ON camps
  FOR SELECT USING (true);

CREATE POLICY camps_director_write ON camps
  FOR ALL
  USING      (director_id = current_setting('app.user_id')::uuid)
  WITH CHECK (director_id = current_setting('app.user_id')::uuid);

-- ── APPLICATIONS: parent sees own child's apps; director sees apps
--                 for their sessions only ──────────────────────────
CREATE POLICY apps_parent_read ON applications
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM children
      WHERE  id = applications.child_id
      AND    parent_id = current_setting('app.user_id')::uuid
    )
  );

CREATE POLICY apps_director_read ON applications
  FOR SELECT USING (
    EXISTS (
      SELECT 1
      FROM   sessions s
      JOIN   camps    c ON c.id = s.camp_id
      WHERE  s.id = applications.session_id
      AND    c.director_id = current_setting('app.user_id')::uuid
    )
  );

-- Director may update status only (not child_id, session_id)
CREATE POLICY apps_director_status ON applications
  FOR UPDATE USING (
    EXISTS (
      SELECT 1
      FROM   sessions s
      JOIN   camps    c ON c.id = s.camp_id
      WHERE  s.id = applications.session_id
      AND    c.director_id = current_setting('app.user_id')::uuid
    )
  );

-- Set current user in every transaction (called by API middleware)
-- SET LOCAL app.user_id = '<uuid>';
🛡️

COPPA Compliance

Children under 13 are a COPPA-regulated class. These controls are non-negotiable.

⚠️
COPPA Scope Any child under 13 whose data is collected, used, or disclosed online is covered by COPPA (15 U.S.C. §§ 6501-6506). OneSummer collects significant PII (name, DOB, health records, location, photos) for this population. Non-compliance carries FTC penalties up to $51,744 per violation.
Requirement Implementation Database Controls
Verifiable Parental Consent Consent collected at account creation; stored in users Add coppa_consent_at TIMESTAMPTZ, coppa_consent_method TEXT to users
Data Minimization Only collect fields with a documented use. JSONB fields should be validated on write. Check constraints on allergies/medications JSONB via trigger; reject unknown top-level keys
Right to Delete Parent-triggered deletion must cascade to all child data ON DELETE CASCADE on all child_id FKs. Documents require S3 deletion job triggered by DB event.
No Targeted Advertising Children's data must not feed ad profiles Separate read replica for analytics with children table columns masked via SECURITY DEFINER view
Data Retention Limits Child records not linked to an active application within 2 years should be purged Scheduled job queries WHERE updated_at < now() - interval '2 years' with no accepted applications
Access Log Track who accessed a child record and when Separate audit_log table: actor_id, table_name, row_id, action, accessed_at
Encryption at Rest All PII encrypted; medical JSONB fields with higher sensitivity Use pgcrypto.pgp_sym_encrypt for allergies, medications columns or rely on full-disk encryption (preferred)
Third-Party Disclosure Camps receive only the fields listed in form_mappings Application server reads form_mappings to project; never sends full child row to camp
coppa_deletion_procedure.sql SQL
-- Verified parental deletion request: wipe child and all related PII
CREATE OR REPLACE PROCEDURE delete_child_data(p_child_id UUID, p_requesting_parent UUID)
LANGUAGE plpgsql AS $$
DECLARE
  v_parent_id UUID;
BEGIN
  -- Verify caller is the parent
  SELECT parent_id INTO v_parent_id
  FROM   children
  WHERE  id = p_child_id;

  IF v_parent_id IS NULL OR v_parent_id != p_requesting_parent THEN
    RAISE EXCEPTION 'Unauthorized deletion attempt for child %', p_child_id;
  END IF;

  -- Collect S3 keys before deletion for async cleanup
  INSERT INTO s3_deletion_queue (file_key, queued_at)
  SELECT file_key, now() FROM documents WHERE child_id = p_child_id;

  -- Cascade delete handles all child-related tables
  DELETE FROM children WHERE id = p_child_id;

  INSERT INTO audit_log (actor_id, action, table_name, row_id, accessed_at)
  VALUES (p_requesting_parent, 'COPPA_DELETE', 'children', p_child_id, now());

  COMMIT;
END; $$;
🔗

Form Mapping Architecture

How OneSummer's "Common App" magic works — one profile, every camp's vocabulary.

The Core Value Proposition Every camp has a different registration form. OneSummer stores a canonical child profile and a mapping table that translates each camp's field labels to the standardized profile paths. When a parent applies, the app pre-fills the camp's form from the profile — zero re-entry required. Camp-specific answers that fall outside the profile (e.g., "Do you play an instrument?") are stored in applications.camp_specific_responses keyed by form_mappings.id.

Canonical Field Registry

canonical_field Source Example Camp Label
child.full_namechildren.full_name"Camper's Full Legal Name"
child.date_of_birthchildren.date_of_birth"Birth Date"
child.gradechildren.grade"Entering Grade (Fall)"
child.tshirt_sizechildren.tshirt_size"T-Shirt Size"
child.allergieschildren.allergies"Known Allergies & Reactions"
child.medicationschildren.medications"Current Medications"
child.dietary_restrictionschildren.dietary_restrictions"Dietary Needs / Restrictions"
child.special_needs_noteschildren.special_needs_notes"Special Accommodations Required"
emergency_contacts.0.nameemergency_contacts row 0"Emergency Contact #1 Name"
emergency_contacts.0.phoneemergency_contacts row 0"Emergency Contact #1 Phone"
insurance.providerinsurance_info.provider"Health Insurance Company"
insurance.policy_numberinsurance_info.policy_number"Policy / Member ID"
documents.physical_examdocuments where category='physical_exam'"Upload Recent Physical Exam"
parent.nameusers.name (via parent_id)"Parent / Guardian Name"
parent.emailusers.email"Parent Email Address"

Pre-fill Query

form_prefill.sql SQL
-- Fetch all form mappings for a camp, with resolved profile values
-- Application layer resolves dot-paths; DB supplies the raw profile

SELECT
  fm.id                AS mapping_id,
  fm.canonical_field,
  fm.camp_field_label,
  fm.required,
  fm.sort_order,
  fm.help_text,

  -- The full child profile row — app resolves canonical_field dot-path
  row_to_json(ch.*)  AS child_profile,
  row_to_json(ii.*)  AS insurance,
  row_to_json(u.*)   AS parent,

  -- Emergency contacts as ordered array
  (SELECT json_agg(ec ORDER BY ec.sort_order)
   FROM emergency_contacts ec
   WHERE ec.child_id = ch.id) AS emergency_contacts

FROM       form_mappings fm
JOIN       children      ch ON ch.id        = $1  -- :child_id
JOIN       users         u  ON u.id         = ch.parent_id
LEFT JOIN  insurance_info ii ON ii.child_id = ch.id
WHERE      fm.camp_id = $2  -- :camp_id
ORDER BY   fm.sort_order;
🔍

Top 10 Queries

The most critical paths — each with index coverage notes.

1 Camp Search with Filters

Primary discovery query. Combines proximity, age range, camp type, price, and full-text search. Powers the main search results page.

q1_camp_search.sql SQL
SELECT
  c.id, c.name, c.city, c.state,
  c.camp_type, c.schedule_type,
  c.age_min, c.age_max,
  c.price_per_week,
  c.scholarship_available,
  c.inclusive_accommodations,
  ROUND(AVG(r.rating), 1)         AS avg_rating,
  COUNT(r.id) FILTER (WHERE r.verified) AS verified_review_count,
  MIN(s.spots_remaining)             AS min_spots_remaining,
  -- Haversine distance
  3959 * acos(least(1.0,
    cos(radians($1)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians($2)) +
    sin(radians($1)) * sin(radians(c.latitude))
  )) AS distance_miles
FROM       camps    c
LEFT JOIN  reviews  r ON r.camp_id = c.id
LEFT JOIN  sessions s ON s.camp_id = c.id AND s.status = 'open'
WHERE
  -- Bounding box (uses idx_camps_geo)
  c.latitude  BETWEEN ($1 - $3/69.0) AND ($1 + $3/69.0)
  AND c.longitude BETWEEN ($2 - $3/(cos(radians($1))*69.0))
                  AND     ($2 + $3/(cos(radians($1))*69.0))
  -- Age filter (uses idx_camps_age)
  AND c.age_min <= $4   -- child's age
  AND c.age_max >= $4
  -- Optional filters
  AND ($5::camp_type IS NULL OR c.camp_type = $5)
  AND ($6::NUMERIC IS NULL OR c.price_per_week <= $6)
  AND ($7::BOOLEAN IS NULL OR c.scholarship_available = $7)
  AND ($8::TEXT IS NULL OR c.search_vector @@ plainto_tsquery('english', $8))
GROUP BY c.id
HAVING
  3959 * acos(least(1.0,
    cos(radians($1)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians($2)) +
    sin(radians($1)) * sin(radians(c.latitude))
  )) <= $3
ORDER BY distance_miles, avg_rating DESC NULLS LAST
LIMIT 25 OFFSET $9;
2 Available Sessions for Age 7 Within 10 Miles of 78704

The canonical "find me a camp this summer" query used in the spec. Returns sessions with spots, ordered by start date and distance.

q2_sessions_age_proximity.sql SQL
-- Zip 78704 (Austin, TX) centroid: 30.2500, -97.7500
SELECT
  s.id              AS session_id,
  c.name            AS camp_name,
  c.city,
  s.start_date,
  s.end_date,
  s.spots_remaining,
  c.price_per_week,
  3959 * acos(least(1.0,
    cos(radians(30.2500)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians(-97.7500)) +
    sin(radians(30.2500)) * sin(radians(c.latitude))
  )) AS distance_miles
FROM      sessions s
JOIN      camps    c ON c.id = s.camp_id
WHERE
  s.status = 'open'
  AND s.start_date >= current_date
  AND c.age_min <= 7
  AND c.age_max >= 7
  -- Bounding box pre-filter (~10 mile box at 30° latitude)
  AND c.latitude  BETWEEN 30.1054 AND 30.3946
  AND c.longitude BETWEEN -97.9169 AND -97.5831
HAVING
  3959 * acos(least(1.0,
    cos(radians(30.2500)) * cos(radians(c.latitude)) *
    cos(radians(c.longitude) - radians(-97.7500)) +
    sin(radians(30.2500)) * sin(radians(c.latitude))
  )) <= 10.0
ORDER BY s.start_date, distance_miles
LIMIT 20;
3 Parent Application Status Dashboard

All active applications for all children of a parent — the main post-login view.

q3_parent_dashboard.sql SQL
SELECT
  ch.full_name         AS child_name,
  c.name               AS camp_name,
  c.city,
  s.start_date,
  s.end_date,
  a.status,
  a.submitted_at,
  a.responded_at,
  a.waitlist_position,
  a.id                 AS application_id
FROM      applications a
JOIN      children     ch ON ch.id      = a.child_id
JOIN      sessions     s  ON s.id       = a.session_id
JOIN      camps        c  ON c.id       = s.camp_id
WHERE     ch.parent_id = $1
AND       a.status != 'withdrawn'
ORDER BY
  CASE a.status
    WHEN 'accepted'     THEN 1
    WHEN 'under_review' THEN 2
    WHEN 'submitted'    THEN 3
    WHEN 'waitlisted'   THEN 4
    WHEN 'draft'        THEN 5
    ELSE 6
  END,
  s.start_date;
4 Director Inbox — Applications for a Session

All submitted applications for a specific session, with child profile summary for quick review.

q4_director_inbox.sql SQL
SELECT
  a.id,
  a.status,
  a.submitted_at,
  a.waitlist_position,
  ch.full_name,
  date_part('year', age(ch.date_of_birth)) AS age,
  ch.grade,
  ch.allergies,
  ch.medications,
  ch.special_needs_notes,
  a.camp_specific_responses
FROM  applications a
JOIN  children     ch ON ch.id = a.child_id
WHERE a.session_id = $1
AND   a.status NOT IN ('draft', 'withdrawn')
ORDER BY
  CASE a.status
    WHEN 'submitted'    THEN 1
    WHEN 'under_review' THEN 2
    WHEN 'waitlisted'   THEN 3
    ELSE 4
  END,
  a.submitted_at;
5 Camp Detail Page — Aggregate Stats + Reviews

Single query for the camp detail page: avg rating, review distribution, upcoming sessions with availability.

q5_camp_detail.sql SQL
SELECT
  c.*,
  ROUND(AVG(r.rating), 2)                          AS avg_rating,
  COUNT(r.id)                                        AS total_reviews,
  COUNT(r.id) FILTER (WHERE r.verified = true)       AS verified_reviews,
  json_agg(
    json_build_object(
      'rating', r.rating,
      'text',   r.text,
      'verified', r.verified,
      'created_at', r.created_at
    ) ORDER BY r.verified DESC, r.created_at DESC
  ) FILTER (WHERE r.id IS NOT NULL)                 AS reviews,

  (SELECT json_agg(sess ORDER BY sess.start_date)
   FROM   sessions sess
   WHERE  sess.camp_id = c.id
   AND    sess.start_date >= current_date)           AS upcoming_sessions

FROM       camps   c
LEFT JOIN  reviews r ON r.camp_id = c.id
WHERE      c.id = $1
GROUP BY   c.id;
6 Child Profile Pre-Fill Completeness

Shows which canonical fields are populated for a child — drives the "profile completion" progress bar in the UI.

q6_profile_completeness.sql SQL
SELECT
  ch.id,
  ch.full_name,
  (ch.date_of_birth       IS NOT NULL)::int +
  (ch.grade               IS NOT NULL)::int +
  (ch.tshirt_size         IS NOT NULL)::int +
  (jsonb_array_length(ch.allergies)   > 0 OR
   ch.dietary_restrictions IS NOT NULL)::int +
  (ii.id                  IS NOT NULL)::int +  -- has insurance
  (EXISTS(SELECT 1 FROM emergency_contacts ec WHERE ec.child_id = ch.id))::int +
  (EXISTS(SELECT 1 FROM documents d WHERE d.child_id = ch.id
          AND d.category = 'physical_exam'))::int AS fields_complete,
  7 AS fields_total
FROM      children     ch
LEFT JOIN insurance_info ii ON ii.child_id = ch.id
WHERE     ch.parent_id = $1;
7 Waitlist Advance — Next in Line

When a spot opens (accepted application withdrawn), promote the first waitlisted applicant. Called inside a transaction by the capacity trigger.

q7_waitlist_advance.sql SQL
UPDATE applications
SET
  status            = 'under_review',
  waitlist_position = NULL,
  responded_at      = now()
WHERE id = (
  SELECT id
  FROM   applications
  WHERE  session_id        = $1
  AND    status            = 'waitlisted'
  ORDER BY waitlist_position ASC NULLS LAST
  LIMIT 1
  FOR UPDATE SKIP LOCKED  -- safe under concurrent access
)
RETURNING id, child_id;
8 Expiring Documents Alert

Background job run weekly — finds physical exams expiring within 60 days that have an upcoming accepted session.

q8_expiring_docs.sql SQL
SELECT
  u.email          AS parent_email,
  u.name           AS parent_name,
  ch.full_name     AS child_name,
  d.name           AS document_name,
  d.expires_at,
  c.name           AS camp_name,
  s.start_date
FROM      documents    d
JOIN      children     ch ON ch.id      = d.child_id
JOIN      users         u  ON u.id       = ch.parent_id
JOIN      applications a  ON a.child_id = ch.id AND a.status = 'accepted'
JOIN      sessions     s  ON s.id       = a.session_id
JOIN      camps        c  ON c.id       = s.camp_id
WHERE     d.category    = 'physical_exam'
AND       d.expires_at  BETWEEN current_date AND current_date + INTERVAL '60 days'
AND       s.start_date  > current_date
ORDER BY  d.expires_at;
9 Director Analytics — Weekly Application Funnel

Shows conversion through the application funnel per camp for the current enrollment season. Director KPI dashboard.

q9_director_funnel.sql SQL
SELECT
  c.name                                               AS camp_name,
  s.start_date,
  s.capacity,
  s.spots_remaining,
  COUNT(*) FILTER (WHERE a.status = 'submitted')    AS submitted,
  COUNT(*) FILTER (WHERE a.status = 'under_review') AS under_review,
  COUNT(*) FILTER (WHERE a.status = 'accepted')    AS accepted,
  COUNT(*) FILTER (WHERE a.status = 'waitlisted')  AS waitlisted,
  COUNT(*) FILTER (WHERE a.status = 'declined')   AS declined,
  ROUND(
    COUNT(*) FILTER (WHERE a.status = 'accepted')::numeric
    / NULLIF(COUNT(*) FILTER (WHERE a.status != 'draft'), 0) * 100, 1
  )                                                    AS acceptance_rate_pct
FROM      camps        c
JOIN      sessions     s  ON s.camp_id   = c.id
LEFT JOIN applications a  ON a.session_id = s.id
WHERE     c.director_id = $1
AND       s.start_date >= date_trunc('year', current_date)
GROUP BY  c.id, c.name, s.id, s.start_date, s.capacity, s.spots_remaining
ORDER BY  s.start_date;
10 Saved Camps with Session Availability

Parent's saved camps enriched with next available session and distance — the wishlist page.

q10_saved_with_availability.sql SQL
SELECT
  c.id, c.name, c.city, c.state,
  c.price_per_week, c.camp_type,
  sc.created_at                     AS saved_at,
  ROUND(AVG(r.rating), 1)           AS avg_rating,
  (SELECT json_build_object(
       'start_date',      ns.start_date,
       'end_date',        ns.end_date,
       'spots_remaining', ns.spots_remaining
   )
   FROM   sessions ns
   WHERE  ns.camp_id    = c.id
   AND    ns.status     = 'open'
   AND    ns.start_date >= current_date
   ORDER BY ns.start_date
   LIMIT 1)                         AS next_open_session
FROM       saved_camps sc
JOIN       camps       c  ON c.id      = sc.camp_id
LEFT JOIN  reviews     r  ON r.camp_id = c.id
WHERE      sc.parent_id = $1
GROUP BY   c.id, c.name, c.city, c.state, c.price_per_week,
           c.camp_type, sc.created_at
ORDER BY   sc.created_at DESC;
📈

Scaling Projections

Summer camp enrollment is acutely seasonal. The database must handle 20x peak-to-trough traffic swings.

Seasonal Traffic Pattern

Month Activity Relative Load Dominant Queries
Jan–Feb Research season begins. Parents start browsing. 20% Camp search, detail pages
Mar–Apr Peak enrollment. 60% of applications submitted. 100% Applications, form pre-fill, session capacity
May Late applications, waitlist churn, document uploads. 70% Waitlist advance, document uploads, expiry alerts
Jun–Aug Camps running. Low application traffic; director ops. 25% Director dashboards, review submissions
Sep–Dec Off-season. New camp listings for next year. 10% Camp creation, next-year session setup

Capacity Thresholds

Camp search QPS (peak March) ~800 QPS
Application writes (peak enrollment day) ~120 TPS
Concurrent connections (peak) ~500
Off-peak sustained load ~40 QPS

Recommendations by Growth Stage

Stage Users Architecture Key Actions
Launch < 10K Single Postgres instance (RDS db.t3.medium) PgBouncer in transaction mode; all indexes from day 1
Growth 10K–100K Primary + 1 read replica Route camp search to replica; add pg_stat_statements monitoring
Scale 100K–1M Primary + 2 read replicas + connection pooler cluster Partition applications by year; Redis cache for camp search results (5-min TTL); consider Citus for camp search fan-out
Peak prep Any Pre-scale March 1, scale down June 15 RDS scheduled scaling; warm PgBouncer pool; pre-VACUUM before enrollment surge; ANALYZE after bulk session inserts
⚙️
Connection Pooling Configuration At 500 concurrent web workers, direct connections would overwhelm Postgres's shared memory. PgBouncer in transaction mode with pool_size=25 per database user allows 500+ application threads to share 25–50 actual backend connections, keeping max_connections at a manageable 100 on the primary.

Partitioning Strategy (Year 2+)

applications_partitioning.sql SQL
-- Range-partition applications by enrollment year
-- (submitted_at year, NULL drafts go to current partition)

CREATE TABLE applications_2025
  PARTITION OF applications
  FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

CREATE TABLE applications_2026
  PARTITION OF applications
  FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

-- Partition pruning kicks in automatically for queries like:
-- WHERE submitted_at >= '2026-01-01'
-- Result: query scans 1 partition instead of full 2M-row table