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.
This commit is contained in:
380
migrations/001_initial_schema.sql
Normal file
380
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,380 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user