Files
play-life/play-life-backend/migrations/000001_baseline.up.sql
2026-02-08 17:01:36 +03:00

498 lines
19 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Baseline Migration: Complete database schema
-- This migration represents the current state of the database schema
-- For existing databases, use: migrate force 1 (do not run this migration)
-- For new databases, this will create the complete schema
-- ============================================
-- Core Tables (no dependencies)
-- ============================================
-- Users table (base for multi-tenancy)
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_users_email ON users(email);
-- Dictionaries table
CREATE TABLE dictionaries (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_dictionaries_user_id ON dictionaries(user_id);
-- Insert default dictionary with id = 0
DO $$
BEGIN
-- Set sequence to -1 so next value will be 0
PERFORM setval('dictionaries_id_seq', -1, false);
-- Insert the default dictionary with id = 0
INSERT INTO dictionaries (id, name)
VALUES (0, 'Все слова')
ON CONFLICT (id) DO NOTHING;
-- Set the sequence to start from 1 (so next auto-increment will be 1)
PERFORM setval('dictionaries_id_seq', 1, false);
EXCEPTION
WHEN others THEN
-- If sequence doesn't exist or other error, try without sequence manipulation
INSERT INTO dictionaries (id, name)
VALUES (0, 'Все слова')
ON CONFLICT (id) DO NOTHING;
END $$;
-- Projects table
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
priority SMALLINT,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_projects_deleted ON projects(deleted);
CREATE INDEX idx_projects_user_id ON projects(user_id);
-- Entries table
CREATE TABLE entries (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_entries_user_id ON entries(user_id);
-- ============================================
-- Dependent Tables
-- ============================================
-- Words table (depends on dictionaries, users)
CREATE TABLE words (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
translation TEXT NOT NULL,
description TEXT,
dictionary_id INTEGER NOT NULL DEFAULT 0 REFERENCES dictionaries(id),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_words_dictionary_id ON words(dictionary_id);
CREATE INDEX idx_words_user_id ON words(user_id);
-- Progress table (depends on words, users)
CREATE TABLE progress (
id SERIAL PRIMARY KEY,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
success INTEGER DEFAULT 0,
failure INTEGER DEFAULT 0,
last_success_at TIMESTAMP,
last_failure_at TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT progress_word_user_unique UNIQUE (word_id, user_id)
);
CREATE INDEX idx_progress_user_id ON progress(user_id);
CREATE UNIQUE INDEX idx_progress_word_user_unique ON progress(word_id, user_id);
-- Configs table (depends on users)
CREATE TABLE configs (
id SERIAL PRIMARY KEY,
words_count INTEGER NOT NULL,
max_cards INTEGER,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_configs_user_id ON configs(user_id);
-- Config dictionaries table (depends on configs, dictionaries)
CREATE TABLE config_dictionaries (
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
PRIMARY KEY (config_id, dictionary_id)
);
CREATE INDEX idx_config_dictionaries_config_id ON config_dictionaries(config_id);
CREATE INDEX idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
-- Nodes table (depends on projects, entries, users)
CREATE TABLE nodes (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
score NUMERIC(8,4),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_nodes_project_id ON nodes(project_id);
CREATE INDEX idx_nodes_entry_id ON nodes(entry_id);
CREATE INDEX idx_nodes_user_id ON nodes(user_id);
-- Weekly goals table (depends on projects, users)
CREATE TABLE weekly_goals (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
goal_year INTEGER NOT NULL,
goal_week INTEGER NOT NULL,
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
max_goal_score NUMERIC(10,4),
max_score NUMERIC(10,4),
priority SMALLINT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
);
CREATE INDEX idx_weekly_goals_project_id ON weekly_goals(project_id);
CREATE INDEX idx_weekly_goals_user_id ON weekly_goals(user_id);
-- Tasks table (depends on users)
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
completed INTEGER DEFAULT 0,
last_completed_at TIMESTAMP WITH TIME ZONE,
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
reward_message TEXT,
progression_base NUMERIC(10,4),
deleted BOOLEAN DEFAULT FALSE,
repetition_period INTERVAL,
next_show_at TIMESTAMP WITH TIME ZONE,
repetition_date TEXT,
config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL,
wishlist_id INTEGER,
reward_policy VARCHAR(20) DEFAULT 'personal'
);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_parent_task_id ON tasks(parent_task_id);
CREATE INDEX idx_tasks_deleted ON tasks(deleted);
CREATE INDEX idx_tasks_last_completed_at ON tasks(last_completed_at);
CREATE INDEX idx_tasks_config_id ON tasks(config_id);
CREATE UNIQUE INDEX idx_tasks_config_id_unique ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
CREATE INDEX idx_tasks_wishlist_id ON tasks(wishlist_id);
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique ON tasks(wishlist_id) WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
CREATE INDEX idx_tasks_id_user_deleted ON tasks(id, user_id, deleted) WHERE deleted = FALSE;
CREATE INDEX idx_tasks_parent_deleted_covering ON tasks(parent_task_id, deleted, id)
INCLUDE (name, completed, last_completed_at, reward_message, progression_base)
WHERE deleted = FALSE;
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
COMMENT ON COLUMN tasks.reward_policy IS 'For wishlist tasks: personal = only if user completes, shared = anyone completes';
-- Reward configs table (depends on tasks, projects)
CREATE TABLE reward_configs (
id SERIAL PRIMARY KEY,
position INTEGER NOT NULL,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
value NUMERIC(10,4) NOT NULL,
use_progression BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_reward_configs_task_id ON reward_configs(task_id);
CREATE INDEX idx_reward_configs_project_id ON reward_configs(project_id);
CREATE UNIQUE INDEX idx_reward_configs_task_position ON reward_configs(task_id, position);
-- Telegram integrations table (depends on users)
CREATE TABLE telegram_integrations (
id SERIAL PRIMARY KEY,
chat_id VARCHAR(255),
telegram_user_id BIGINT,
start_token VARCHAR(255),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_telegram_integrations_user_id_unique ON telegram_integrations(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_telegram_integrations_user_id ON telegram_integrations(user_id);
CREATE UNIQUE INDEX idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL;
CREATE UNIQUE INDEX idx_telegram_integrations_telegram_user_id ON telegram_integrations(telegram_user_id) WHERE telegram_user_id IS NOT NULL;
CREATE INDEX idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL;
-- Todoist integrations table (depends on users)
CREATE TABLE todoist_integrations (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
todoist_user_id BIGINT,
todoist_email VARCHAR(255),
access_token TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
);
CREATE INDEX idx_todoist_integrations_user_id ON todoist_integrations(user_id);
CREATE UNIQUE INDEX idx_todoist_integrations_todoist_user_id ON todoist_integrations(todoist_user_id) WHERE todoist_user_id IS NOT NULL;
CREATE UNIQUE INDEX idx_todoist_integrations_todoist_email ON todoist_integrations(todoist_email) WHERE todoist_email IS NOT NULL;
-- Wishlist boards table (depends on users)
CREATE TABLE wishlist_boards (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
invite_token VARCHAR(64) UNIQUE,
invite_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_wishlist_boards_owner_id ON wishlist_boards(owner_id);
CREATE INDEX idx_wishlist_boards_invite_token ON wishlist_boards(invite_token) WHERE invite_token IS NOT NULL;
CREATE INDEX idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted);
-- Wishlist board members table (depends on wishlist_boards, users)
CREATE TABLE wishlist_board_members (
id SERIAL PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES wishlist_boards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_board_member UNIQUE (board_id, user_id)
);
CREATE INDEX idx_board_members_board_id ON wishlist_board_members(board_id);
CREATE INDEX idx_board_members_user_id ON wishlist_board_members(user_id);
-- Wishlist items table (depends on users, wishlist_boards)
CREATE TABLE wishlist_items (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
price NUMERIC(10,2),
image_path VARCHAR(500),
link TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE,
board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_wishlist_items_user_id ON wishlist_items(user_id);
CREATE INDEX idx_wishlist_items_user_deleted ON wishlist_items(user_id, deleted);
CREATE INDEX idx_wishlist_items_user_completed ON wishlist_items(user_id, completed, deleted);
CREATE INDEX idx_wishlist_items_board_id ON wishlist_items(board_id);
CREATE INDEX idx_wishlist_items_author_id ON wishlist_items(author_id);
CREATE INDEX idx_wishlist_items_id_deleted_covering ON wishlist_items(id, deleted)
INCLUDE (name)
WHERE deleted = FALSE;
-- Add foreign key for tasks.wishlist_id after wishlist_items is created
ALTER TABLE tasks ADD CONSTRAINT tasks_wishlist_id_fkey
FOREIGN KEY (wishlist_id) REFERENCES wishlist_items(id) ON DELETE SET NULL;
COMMENT ON TABLE wishlist_items IS 'Wishlist items for users';
COMMENT ON COLUMN wishlist_items.completed IS 'Flag indicating item was purchased/received';
COMMENT ON COLUMN wishlist_items.image_path IS 'Path to image file relative to uploads root';
-- Task conditions table (depends on tasks)
CREATE TABLE task_conditions (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_task_condition UNIQUE (task_id)
);
CREATE INDEX idx_task_conditions_task_id ON task_conditions(task_id);
COMMENT ON TABLE task_conditions IS 'Reusable unlock conditions based on task completion';
-- Score conditions table (depends on projects, users)
CREATE TABLE score_conditions (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
required_points NUMERIC(10,4) NOT NULL,
start_date DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT unique_score_condition UNIQUE (project_id, required_points, start_date)
);
CREATE INDEX idx_score_conditions_project_id ON score_conditions(project_id);
CREATE INDEX idx_score_conditions_user_id ON score_conditions(user_id);
COMMENT ON TABLE score_conditions IS 'Reusable unlock conditions based on project points';
COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.';
-- Wishlist conditions table (depends on wishlist_items, task_conditions, score_conditions, users)
CREATE TABLE wishlist_conditions (
id SERIAL PRIMARY KEY,
wishlist_item_id INTEGER NOT NULL REFERENCES wishlist_items(id) ON DELETE CASCADE,
task_condition_id INTEGER REFERENCES task_conditions(id) ON DELETE CASCADE,
score_condition_id INTEGER REFERENCES score_conditions(id) ON DELETE CASCADE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT check_exactly_one_condition CHECK (
(task_condition_id IS NOT NULL AND score_condition_id IS NULL) OR
(task_condition_id IS NULL AND score_condition_id IS NOT NULL)
)
);
CREATE INDEX idx_wishlist_conditions_item_id ON wishlist_conditions(wishlist_item_id);
CREATE INDEX idx_wishlist_conditions_item_order ON wishlist_conditions(wishlist_item_id, display_order);
CREATE INDEX idx_wishlist_conditions_task_condition_id ON wishlist_conditions(task_condition_id);
CREATE INDEX idx_wishlist_conditions_score_condition_id ON wishlist_conditions(score_condition_id);
CREATE INDEX idx_wishlist_conditions_user_id ON wishlist_conditions(user_id);
COMMENT ON TABLE wishlist_conditions IS 'Links between wishlist items and unlock conditions. Multiple conditions per item use AND logic.';
COMMENT ON COLUMN wishlist_conditions.display_order IS 'Order for displaying conditions in UI';
COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards';
-- Refresh tokens table (depends on users)
CREATE TABLE refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
-- ============================================
-- Materialized Views
-- ============================================
-- Weekly report materialized view
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot.';
-- ============================================
-- Comments
-- ============================================
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';
COMMENT ON TABLE wishlist_boards IS 'Wishlist boards for organizing and sharing wishes';
COMMENT ON COLUMN wishlist_boards.invite_token IS 'Token for invite link, NULL = disabled';
COMMENT ON COLUMN wishlist_boards.invite_enabled IS 'Whether invite link is active';
COMMENT ON TABLE wishlist_board_members IS 'Users who joined boards via invite link (not owners)';
COMMENT ON COLUMN wishlist_items.author_id IS 'User who created this item (may differ from board owner on shared boards)';
COMMENT ON COLUMN wishlist_items.board_id IS 'Board this item belongs to';
-- ============================================
-- Additional Tables
-- ============================================
-- Eateries table
CREATE TABLE eateries (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
address VARCHAR(255),
type VARCHAR(50),
distance DOUBLE PRECISION
);
-- Interesting places table
CREATE TABLE interesting_places (
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT,
description TEXT,
added_at TIMESTAMP WITH TIME ZONE,
is_visited BOOLEAN,
phone_number TEXT,
address TEXT,
updated_at TIMESTAMP WITH TIME ZONE
);
-- Music groups table
CREATE TABLE music_groups (
id INTEGER PRIMARY KEY,
name TEXT,
possible_locations TEXT
);
-- N8N chat histories table
CREATE TABLE n8n_chat_histories (
id SERIAL PRIMARY KEY,
session_id VARCHAR(255) NOT NULL,
message JSONB NOT NULL
);
-- Places to visit table
CREATE TABLE places_to_visit (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
city TEXT,
description TEXT,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_visited BOOLEAN DEFAULT FALSE,
phone_number TEXT,
address TEXT,
updated_at TIMESTAMP WITH TIME ZONE
);
-- Restaurants table
CREATE TABLE restaurants (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
address VARCHAR(255),
contact_info VARCHAR(255)
);
-- Upcoming concerts table (depends on music_groups)
CREATE TABLE upcoming_concerts (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES music_groups(id),
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL,
venue TEXT,
city TEXT,
tickets_url TEXT
);
CREATE UNIQUE INDEX idx_unique_concert ON upcoming_concerts(scheduled_at, city, group_id);