-- CleanPlate Initial Schema -- Migration 001: Core tables for MVP BEGIN; -- ============================================================ -- EXTENSIONS -- ============================================================ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================ -- USERS & AUTH -- ============================================================ CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, display_name VARCHAR(100) NOT NULL, avatar_url VARCHAR(500), subscription_tier VARCHAR(20) DEFAULT 'free', subscription_expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL; CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), revoked_at TIMESTAMPTZ ); CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id) WHERE revoked_at IS NULL; CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; CREATE TABLE dietary_profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, diet_type VARCHAR(50), allergies TEXT[] DEFAULT '{}', intolerances TEXT[] DEFAULT '{}', calorie_target INTEGER, excluded_ingredients TEXT[] DEFAULT '{}', preferences_json JSONB DEFAULT '{}', updated_at TIMESTAMPTZ DEFAULT NOW() ); -- ============================================================ -- HOUSEHOLDS -- ============================================================ CREATE TABLE households ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, owner_id UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE household_members ( household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(20) DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (household_id, user_id) ); -- ============================================================ -- CANONICAL INGREDIENTS (reference dictionary) -- ============================================================ CREATE TABLE canonical_ingredients ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) UNIQUE NOT NULL, category VARCHAR(50), aliases TEXT[] DEFAULT '{}', nutrition_per_100g JSONB, common_units TEXT[] DEFAULT '{}' ); CREATE INDEX idx_canonical_name ON canonical_ingredients(name); -- ============================================================ -- RECIPES -- ============================================================ CREATE TABLE recipes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), author_id UUID REFERENCES users(id), title VARCHAR(300) NOT NULL, slug VARCHAR(350) UNIQUE NOT NULL, description TEXT, cuisine VARCHAR(50), difficulty VARCHAR(20), prep_time_min INTEGER, cook_time_min INTEGER, total_time_min INTEGER GENERATED ALWAYS AS ( COALESCE(prep_time_min, 0) + COALESCE(cook_time_min, 0) ) STORED, servings INTEGER DEFAULT 4, source_type VARCHAR(20) DEFAULT 'user', status VARCHAR(20) DEFAULT 'published', cover_image_url VARCHAR(500), video_url VARCHAR(500), tags TEXT[] DEFAULT '{}', diet_labels TEXT[] DEFAULT '{}', rating_avg NUMERIC(3,2) DEFAULT 0, rating_count INTEGER DEFAULT 0, save_count INTEGER DEFAULT 0, version INTEGER DEFAULT 1, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), published_at TIMESTAMPTZ ); CREATE INDEX idx_recipes_cuisine ON recipes(cuisine); CREATE INDEX idx_recipes_difficulty ON recipes(difficulty); CREATE INDEX idx_recipes_total_time ON recipes(total_time_min); CREATE INDEX idx_recipes_rating ON recipes(rating_avg DESC); CREATE INDEX idx_recipes_tags ON recipes USING GIN(tags); CREATE INDEX idx_recipes_diet_labels ON recipes USING GIN(diet_labels); CREATE INDEX idx_recipes_status ON recipes(status) WHERE status = 'published'; CREATE INDEX idx_recipes_author ON recipes(author_id); -- Full-text search vector ALTER TABLE recipes ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(description, '')), 'B') || setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C') ) STORED; CREATE INDEX idx_recipes_search ON recipes USING GIN(search_vector); -- ============================================================ -- INGREDIENTS -- ============================================================ CREATE TABLE ingredients ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, name VARCHAR(200) NOT NULL, quantity NUMERIC(10,3), unit VARCHAR(50), group_name VARCHAR(100), sort_order INTEGER NOT NULL, optional BOOLEAN DEFAULT FALSE, canonical_ingredient_id UUID REFERENCES canonical_ingredients(id) ); CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id); CREATE INDEX idx_ingredients_canonical ON ingredients(canonical_ingredient_id); -- ============================================================ -- RECIPE STEPS -- ============================================================ CREATE TABLE recipe_steps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, step_number INTEGER NOT NULL, instruction TEXT NOT NULL, duration_min INTEGER, image_url VARCHAR(500), tip TEXT, UNIQUE(recipe_id, step_number) ); CREATE INDEX idx_steps_recipe ON recipe_steps(recipe_id); -- ============================================================ -- RECIPE NUTRITION -- ============================================================ CREATE TABLE recipe_nutrition ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipe_id UUID UNIQUE NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, per_serving BOOLEAN DEFAULT TRUE, calories NUMERIC(8,2), protein_g NUMERIC(8,2), fat_g NUMERIC(8,2), saturated_fat_g NUMERIC(8,2), carbs_g NUMERIC(8,2), fiber_g NUMERIC(8,2), sugar_g NUMERIC(8,2), sodium_mg NUMERIC(8,2), calculated_at TIMESTAMPTZ DEFAULT NOW(), source VARCHAR(20) DEFAULT 'auto' ); -- ============================================================ -- COMMUNITY (Reviews) -- ============================================================ CREATE TABLE reviews ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), title VARCHAR(200), body TEXT, helpful_count INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'published', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(recipe_id, user_id) ); CREATE INDEX idx_reviews_recipe ON reviews(recipe_id, created_at DESC); CREATE INDEX idx_reviews_user ON reviews(user_id); CREATE TABLE review_reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, reporter_id UUID NOT NULL REFERENCES users(id), reason VARCHAR(50) NOT NULL, details TEXT, status VARCHAR(20) DEFAULT 'pending', created_at TIMESTAMPTZ DEFAULT NOW() ); -- ============================================================ -- SAVED RECIPES & COLLECTIONS -- ============================================================ CREATE TABLE saved_recipes ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, saved_at TIMESTAMPTZ DEFAULT NOW(), synced_at TIMESTAMPTZ, PRIMARY KEY (user_id, recipe_id) ); CREATE TABLE collections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, is_public BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE collection_recipes ( collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, added_at TIMESTAMPTZ DEFAULT NOW(), sort_order INTEGER, PRIMARY KEY (collection_id, recipe_id) ); -- ============================================================ -- MEAL PLANNING -- ============================================================ CREATE TABLE meal_plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, household_id UUID REFERENCES households(id), name VARCHAR(100), start_date DATE NOT NULL, end_date DATE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE meal_plan_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, recipe_id UUID NOT NULL REFERENCES recipes(id), day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), meal_type VARCHAR(20) NOT NULL, servings INTEGER DEFAULT 1, notes TEXT, sort_order INTEGER ); CREATE TABLE grocery_lists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, generated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE grocery_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), list_id UUID NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE, ingredient_name VARCHAR(200) NOT NULL, quantity NUMERIC(10,3), unit VARCHAR(50), category VARCHAR(50), checked BOOLEAN DEFAULT FALSE, added_manually BOOLEAN DEFAULT FALSE ); -- ============================================================ -- MEDIA -- ============================================================ CREATE TABLE media ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), uploader_id UUID NOT NULL REFERENCES users(id), type VARCHAR(20) NOT NULL, original_url VARCHAR(500) NOT NULL, processed_url VARCHAR(500), thumbnail_url VARCHAR(500), status VARCHAR(20) DEFAULT 'processing', mime_type VARCHAR(100), size_bytes BIGINT, duration_sec INTEGER, width INTEGER, height INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() ); -- ============================================================ -- AI GENERATION LOG -- ============================================================ CREATE TABLE ai_generations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), prompt_hash VARCHAR(64) NOT NULL, input_json JSONB NOT NULL, output_json JSONB, model_used VARCHAR(100), tokens_in INTEGER, tokens_out INTEGER, latency_ms INTEGER, cached BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_ai_gen_prompt_hash ON ai_generations(prompt_hash); CREATE INDEX idx_ai_gen_user ON ai_generations(user_id, created_at DESC); -- ============================================================ -- SYNC TRACKING -- ============================================================ CREATE TABLE sync_log ( id BIGSERIAL PRIMARY KEY, entity_type VARCHAR(50) NOT NULL, entity_id UUID NOT NULL, action VARCHAR(20) NOT NULL, changed_at TIMESTAMPTZ DEFAULT NOW(), changed_by UUID REFERENCES users(id) ); CREATE INDEX idx_sync_log_type_time ON sync_log(entity_type, changed_at); -- ============================================================ -- UPDATED_AT TRIGGER FUNCTION -- ============================================================ CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; -- Apply trigger to tables with updated_at CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_recipes_updated_at BEFORE UPDATE ON recipes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_meal_plans_updated_at BEFORE UPDATE ON meal_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); COMMIT;