Dart Shelf REST API with auth, recipes, AI (Claude), search, and community modules. PostgreSQL, Redis, Meilisearch. Docker Compose for local dev.
381 lines
14 KiB
PL/PgSQL
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;
|