Files

498 lines
19 KiB
MySQL
Raw Permalink Normal View History

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