OneSummer
Data Model
PostgreSQL schema for the Common App of summer camps — one child profile, every camp's form, zero repeated paperwork.
Visual ERD
Entity relationships and cardinality for all 12 tables.
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.
-- 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.
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.
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: 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.
-- ── 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.
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.
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
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.
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.
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
-- 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
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.
- 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
- 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
- Bypass all RLS via
SET ROLE - Full access all tables
- GDPR/COPPA deletion workflows
-- 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.
| 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 |
-- 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.
applications.camp_specific_responses keyed by form_mappings.id.
Canonical Field Registry
| canonical_field | Source | Example Camp Label |
|---|---|---|
child.full_name | children.full_name | "Camper's Full Legal Name" |
child.date_of_birth | children.date_of_birth | "Birth Date" |
child.grade | children.grade | "Entering Grade (Fall)" |
child.tshirt_size | children.tshirt_size | "T-Shirt Size" |
child.allergies | children.allergies | "Known Allergies & Reactions" |
child.medications | children.medications | "Current Medications" |
child.dietary_restrictions | children.dietary_restrictions | "Dietary Needs / Restrictions" |
child.special_needs_notes | children.special_needs_notes | "Special Accommodations Required" |
emergency_contacts.0.name | emergency_contacts row 0 | "Emergency Contact #1 Name" |
emergency_contacts.0.phone | emergency_contacts row 0 | "Emergency Contact #1 Phone" |
insurance.provider | insurance_info.provider | "Health Insurance Company" |
insurance.policy_number | insurance_info.policy_number | "Policy / Member ID" |
documents.physical_exam | documents where category='physical_exam' | "Upload Recent Physical Exam" |
parent.name | users.name (via parent_id) | "Parent / Guardian Name" |
parent.email | users.email | "Parent Email Address" |
Pre-fill Query
-- 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.
Primary discovery query. Combines proximity, age range, camp type, price, and full-text search. Powers the main search results page.
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;
The canonical "find me a camp this summer" query used in the spec. Returns sessions with spots, ordered by start date and distance.
-- 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;
All active applications for all children of a parent — the main post-login view.
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;
All submitted applications for a specific session, with child profile summary for quick review.
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;
Single query for the camp detail page: avg rating, review distribution, upcoming sessions with availability.
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;
Shows which canonical fields are populated for a child — drives the "profile completion" progress bar in the UI.
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;
When a spot opens (accepted application withdrawn), promote the first waitlisted applicant. Called inside a transaction by the capacity trigger.
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;
Background job run weekly — finds physical exams expiring within 60 days that have an upcoming accepted session.
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;
Shows conversion through the application funnel per camp for the current enrollment season. Director KPI dashboard.
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;
Parent's saved camps enriched with next available session and distance — the wishlist page.
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
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 |
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+)
-- 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