Files
cleanplate_api/migrations/001_initial_schema.sql
Oracle Public Cloud User 6bd1ab7e9f Initial commit: CleanPlate backend API
Dart Shelf REST API with auth, recipes, AI (Claude), search, and community modules.
PostgreSQL, Redis, Meilisearch. Docker Compose for local dev.
2026-03-04 14:52:13 +00:00

381 lines
14 KiB
PL/PgSQL

-- 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;