3
play-life-backend/migrations/000001_baseline.down.sql
Normal file
3
play-life-backend/migrations/000001_baseline.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Baseline migration cannot be rolled back
|
||||
-- This is the initial state of the database schema
|
||||
-- If you need to revert, you must manually drop all tables and recreate from scratch
|
||||
497
play-life-backend/migrations/000001_baseline.up.sql
Normal file
497
play-life-backend/migrations/000001_baseline.up.sql
Normal file
@@ -0,0 +1,497 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Rollback migration: This migration cannot be automatically rolled back
|
||||
-- The user_id values were corrected from projects.user_id, so reverting would
|
||||
-- require knowing the original incorrect values, which is not possible.
|
||||
-- If rollback is needed, you would need to manually restore from a backup.
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Fix weekly_goals.user_id by updating it from projects.user_id
|
||||
-- This migration fixes the issue where weekly_goals.user_id was incorrectly set to NULL or wrong user_id
|
||||
-- It updates all weekly_goals records to have the correct user_id from their associated project
|
||||
|
||||
UPDATE weekly_goals wg
|
||||
SET user_id = p.user_id
|
||||
FROM projects p
|
||||
WHERE wg.project_id = p.id
|
||||
AND (wg.user_id IS NULL OR wg.user_id != p.user_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Rollback migration: Remove covering index for reward_configs
|
||||
|
||||
DROP INDEX IF EXISTS idx_reward_configs_task_id_covering;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Migration: Add covering index for reward_configs to optimize subtask rewards queries
|
||||
-- Date: 2026-01-26
|
||||
--
|
||||
-- This migration adds a covering index to optimize queries that load rewards for multiple subtasks.
|
||||
-- The index includes all columns needed for the query, allowing PostgreSQL to perform
|
||||
-- index-only scans without accessing the main table.
|
||||
--
|
||||
-- Covering index for reward_configs query
|
||||
-- Includes all columns needed for rewards selection to avoid table lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id_covering
|
||||
ON reward_configs(task_id, position)
|
||||
INCLUDE (id, project_id, value, use_progression);
|
||||
|
||||
COMMENT ON INDEX idx_reward_configs_task_id_covering IS 'Covering index for rewards query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance when loading rewards for multiple tasks.';
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Migration: Revert optimization of weekly_report_mv
|
||||
-- Date: 2026-01-26
|
||||
--
|
||||
-- This migration reverts:
|
||||
-- 1. Removes created_date column from nodes table
|
||||
-- 2. Drops indexes
|
||||
-- 3. Restores MV to original structure (include current week, use entries.created_date)
|
||||
|
||||
-- ============================================
|
||||
-- Step 1: Recreate MV with original structure
|
||||
-- ============================================
|
||||
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||
|
||||
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);
|
||||
|
||||
-- ============================================
|
||||
-- Step 2: Drop indexes
|
||||
-- ============================================
|
||||
DROP INDEX IF EXISTS idx_nodes_project_user_created_date;
|
||||
DROP INDEX IF EXISTS idx_nodes_created_date_user;
|
||||
|
||||
-- ============================================
|
||||
-- Step 3: Remove created_date column from nodes
|
||||
-- ============================================
|
||||
ALTER TABLE nodes
|
||||
DROP COLUMN IF EXISTS created_date;
|
||||
|
||||
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.';
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Migration: Optimize weekly_report_mv by denormalizing created_date into nodes and excluding current week from MV
|
||||
-- Date: 2026-01-26
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Adds created_date column to nodes table (denormalization to avoid JOIN with entries)
|
||||
-- 2. Populates existing data from entries
|
||||
-- 3. Creates indexes for optimized queries
|
||||
-- 4. Updates MV to exclude current week and use nodes.created_date instead of entries.created_date
|
||||
|
||||
-- ============================================
|
||||
-- Step 1: Add created_date column to nodes
|
||||
-- ============================================
|
||||
ALTER TABLE nodes
|
||||
ADD COLUMN created_date TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- ============================================
|
||||
-- Step 2: Populate existing data from entries
|
||||
-- ============================================
|
||||
UPDATE nodes n
|
||||
SET created_date = e.created_date
|
||||
FROM entries e
|
||||
WHERE n.entry_id = e.id;
|
||||
|
||||
-- ============================================
|
||||
-- Step 3: Set NOT NULL constraint
|
||||
-- ============================================
|
||||
ALTER TABLE nodes
|
||||
ALTER COLUMN created_date SET NOT NULL;
|
||||
|
||||
-- ============================================
|
||||
-- Step 4: Create indexes for optimized queries
|
||||
-- ============================================
|
||||
-- Index for filtering by date and user (for current week queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_created_date_user
|
||||
ON nodes(created_date, user_id);
|
||||
|
||||
-- Index for queries with grouping by project (for current week queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_project_user_created_date
|
||||
ON nodes(project_id, user_id, created_date);
|
||||
|
||||
COMMENT ON INDEX idx_nodes_created_date_user IS 'Index for filtering nodes by created_date and user_id - optimized for current week queries';
|
||||
COMMENT ON INDEX idx_nodes_project_user_created_date IS 'Index for grouping nodes by project, user and created_date - optimized for current week aggregation queries';
|
||||
|
||||
-- ============================================
|
||||
-- Step 5: Recreate MV to exclude current week and use nodes.created_date
|
||||
-- ============================================
|
||||
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||
|
||||
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 n.created_date)::INTEGER AS report_year,
|
||||
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
|
||||
SUM(n.score) AS total_score
|
||||
FROM
|
||||
nodes n
|
||||
WHERE
|
||||
-- Exclude current week: only include data from previous weeks
|
||||
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||
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;
|
||||
|
||||
-- Recreate index on MV
|
||||
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. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Migration: Remove task drafts tables
|
||||
-- Date: 2026-01-26
|
||||
--
|
||||
-- This migration removes tables created for task drafts
|
||||
|
||||
DROP TABLE IF EXISTS task_draft_subtasks;
|
||||
DROP TABLE IF EXISTS task_drafts;
|
||||
45
play-life-backend/migrations/000005_add_task_drafts.up.sql
Normal file
45
play-life-backend/migrations/000005_add_task_drafts.up.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Migration: Add task drafts tables
|
||||
-- Date: 2026-01-26
|
||||
--
|
||||
-- This migration creates tables for storing task drafts:
|
||||
-- 1. task_drafts - main table for task drafts with progression value and auto_complete flag
|
||||
-- 2. task_draft_subtasks - stores only checked subtask IDs for each draft
|
||||
|
||||
-- ============================================
|
||||
-- Table: task_drafts
|
||||
-- ============================================
|
||||
CREATE TABLE task_drafts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
progression_value NUMERIC(10,4),
|
||||
auto_complete BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_drafts_task_id ON task_drafts(task_id);
|
||||
CREATE INDEX idx_task_drafts_user_id ON task_drafts(user_id);
|
||||
CREATE INDEX idx_task_drafts_auto_complete ON task_drafts(auto_complete) WHERE auto_complete = TRUE;
|
||||
|
||||
COMMENT ON TABLE task_drafts IS 'Stores draft states for tasks with progression value and auto-complete flag';
|
||||
COMMENT ON COLUMN task_drafts.progression_value IS 'Saved progression value from user input';
|
||||
COMMENT ON COLUMN task_drafts.auto_complete IS 'Flag indicating task should be auto-completed at end of day (23:55)';
|
||||
COMMENT ON COLUMN task_drafts.task_id IS 'Reference to task. UNIQUE constraint ensures one draft per task';
|
||||
|
||||
-- ============================================
|
||||
-- Table: task_draft_subtasks
|
||||
-- ============================================
|
||||
CREATE TABLE task_draft_subtasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_draft_id INTEGER REFERENCES task_drafts(id) ON DELETE CASCADE,
|
||||
subtask_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE(task_draft_id, subtask_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_draft_subtasks_task_draft_id ON task_draft_subtasks(task_draft_id);
|
||||
CREATE INDEX idx_task_draft_subtasks_subtask_id ON task_draft_subtasks(subtask_id);
|
||||
|
||||
COMMENT ON TABLE task_draft_subtasks IS 'Stores only checked subtask IDs for each draft. If subtask is not in this table, it means it is unchecked';
|
||||
COMMENT ON COLUMN task_draft_subtasks.subtask_id IS 'Reference to subtask task. Only checked subtasks are stored here';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration: Revert wishlist_id unique index fix
|
||||
-- Date: 2026-01-30
|
||||
--
|
||||
-- This migration reverts the composite unique index back to the original
|
||||
-- unique index that only checked wishlist_id.
|
||||
|
||||
-- Drop the composite unique index
|
||||
DROP INDEX IF EXISTS idx_tasks_wishlist_id_user_id_unique;
|
||||
|
||||
-- Restore the original unique index on wishlist_id only
|
||||
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique
|
||||
ON tasks(wishlist_id)
|
||||
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration: Fix wishlist_id unique index to allow multiple users
|
||||
-- Date: 2026-01-30
|
||||
--
|
||||
-- This migration fixes the unique index on wishlist_id to allow multiple users
|
||||
-- to create tasks for the same wishlist item. The old index only checked wishlist_id,
|
||||
-- but now we need a composite unique index on (wishlist_id, user_id).
|
||||
|
||||
-- Drop the old unique index that only checked wishlist_id
|
||||
DROP INDEX IF EXISTS idx_tasks_wishlist_id_unique;
|
||||
|
||||
-- Create a new composite unique index on (wishlist_id, user_id)
|
||||
-- This allows multiple users to have tasks for the same wishlist item,
|
||||
-- but prevents the same user from having multiple tasks for the same wishlist item
|
||||
CREATE UNIQUE INDEX idx_tasks_wishlist_id_user_id_unique
|
||||
ON tasks(wishlist_id, user_id)
|
||||
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration: Drop projects_median_mv materialized view
|
||||
-- Date: 2026-01-30
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration: Add projects_median_mv materialized view
|
||||
-- Date: 2026-01-30
|
||||
--
|
||||
-- This migration creates a materialized view that calculates the median score
|
||||
-- for each project based on the last 12 weeks of historical data from weekly_report_mv.
|
||||
-- The view includes user_id to support multi-tenant queries.
|
||||
|
||||
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
p.user_id,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
normalized_total_score,
|
||||
report_year,
|
||||
report_week,
|
||||
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||
FROM weekly_report_mv
|
||||
WHERE
|
||||
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||
) sub
|
||||
JOIN projects p ON p.id = sub.project_id
|
||||
WHERE rn <= 12 AND p.deleted = FALSE
|
||||
GROUP BY p.id, p.user_id
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 12 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration: Revert median calculation back to 12 weeks
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration reverts projects_median_mv back to using 12 weeks.
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
p.user_id,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
normalized_total_score,
|
||||
report_year,
|
||||
report_week,
|
||||
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||
FROM weekly_report_mv
|
||||
WHERE
|
||||
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||
) sub
|
||||
JOIN projects p ON p.id = sub.project_id
|
||||
WHERE rn <= 12 AND p.deleted = FALSE
|
||||
GROUP BY p.id, p.user_id
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 12 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Migration: Change median calculation from 12 weeks to 4 weeks
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration updates projects_median_mv to calculate median based on
|
||||
-- the last 4 weeks instead of 12 weeks.
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
p.user_id,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
normalized_total_score,
|
||||
report_year,
|
||||
report_week,
|
||||
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||
FROM weekly_report_mv
|
||||
WHERE
|
||||
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||
) sub
|
||||
JOIN projects p ON p.id = sub.project_id
|
||||
WHERE rn <= 4 AND p.deleted = FALSE
|
||||
GROUP BY p.id, p.user_id
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Remove is_admin field from users table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration reverts the addition of is_admin field.
|
||||
|
||||
DROP INDEX IF EXISTS idx_users_is_admin;
|
||||
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS is_admin;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration: Add is_admin field to users table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration adds is_admin boolean field to users table to identify admin users.
|
||||
-- Default value is FALSE, so existing users will not become admins automatically.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX idx_users_is_admin ON users(is_admin);
|
||||
|
||||
COMMENT ON COLUMN users.is_admin IS 'Indicates if the user has admin privileges';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Remove project_id field from wishlist_items table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration reverts the addition of project_id field.
|
||||
|
||||
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
|
||||
|
||||
ALTER TABLE wishlist_items
|
||||
DROP COLUMN IF EXISTS project_id;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration: Add project_id field to wishlist_items table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration adds project_id field to wishlist_items table to allow
|
||||
-- grouping wishlist items by project. The field is nullable, so existing
|
||||
-- items without a project will remain valid.
|
||||
|
||||
ALTER TABLE wishlist_items
|
||||
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
|
||||
|
||||
COMMENT ON COLUMN wishlist_items.project_id IS 'Project this wishlist item belongs to (optional)';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Remove color field from projects table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration removes the color field from projects table.
|
||||
|
||||
DROP INDEX IF EXISTS idx_projects_color;
|
||||
|
||||
ALTER TABLE projects
|
||||
DROP COLUMN IF EXISTS color;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration: Add color field to projects table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration adds color field to projects table to allow
|
||||
-- custom color selection for projects. The field is NOT NULL,
|
||||
-- and existing projects will be assigned colors from a predefined palette.
|
||||
|
||||
-- Добавляем поле color
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN color VARCHAR(7) NOT NULL DEFAULT '#3B82F6';
|
||||
|
||||
-- Палитра из 30 контрастных цветов (синхронизирована с backend и frontend)
|
||||
-- Заполняем существующие проекты цветами из палитры
|
||||
DO $$
|
||||
DECLARE
|
||||
colors TEXT[] := ARRAY[
|
||||
'#EF4444', '#F97316', '#F59E0B', '#EAB308', '#84CC16',
|
||||
'#22C55E', '#10B981', '#14B8A6', '#06B6D4', '#0EA5E9',
|
||||
'#3B82F6', '#6366F1', '#8B5CF6', '#A855F7', '#D946EF',
|
||||
'#EC4899', '#F43F5E', '#DC2626', '#EA580C', '#CA8A04',
|
||||
'#65A30D', '#16A34A', '#059669', '#0D9488', '#0891B2',
|
||||
'#0284C7', '#2563EB', '#4F46E5', '#7C3AED', '#9333EA'
|
||||
];
|
||||
project_record RECORD;
|
||||
color_index INTEGER := 0;
|
||||
BEGIN
|
||||
-- Обновляем существующие проекты, присваивая им цвета из палитры
|
||||
FOR project_record IN
|
||||
SELECT id FROM projects ORDER BY id
|
||||
LOOP
|
||||
UPDATE projects
|
||||
SET color = colors[1 + (color_index % array_length(colors, 1))]
|
||||
WHERE id = project_record.id;
|
||||
|
||||
color_index := color_index + 1;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Убираем DEFAULT, так как теперь все проекты имеют цвет
|
||||
ALTER TABLE projects
|
||||
ALTER COLUMN color DROP DEFAULT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_color ON projects(color);
|
||||
|
||||
COMMENT ON COLUMN projects.color IS 'Project color in HEX format (e.g., #FF5733)';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Remove position field from tasks table
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration removes the position field from tasks table.
|
||||
|
||||
DROP INDEX IF EXISTS idx_tasks_parent_position;
|
||||
|
||||
ALTER TABLE tasks
|
||||
DROP COLUMN IF EXISTS position;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migration: Add position field to tasks table for subtasks ordering
|
||||
-- Date: 2026-02-02
|
||||
--
|
||||
-- This migration adds position field to tasks table to allow
|
||||
-- custom ordering of subtasks. The field is NULL for regular tasks
|
||||
-- and contains position number for subtasks (tasks with parent_task_id).
|
||||
|
||||
-- Добавляем поле position
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN position INTEGER;
|
||||
|
||||
-- Заполняем позиции для всех существующих подзадач
|
||||
-- Позиции присваиваются по порядку id в рамках каждой родительской задачи
|
||||
DO $$
|
||||
DECLARE
|
||||
parent_record RECORD;
|
||||
subtask_record RECORD;
|
||||
pos INTEGER;
|
||||
BEGIN
|
||||
-- Для каждой родительской задачи
|
||||
FOR parent_record IN
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
ORDER BY parent_task_id
|
||||
LOOP
|
||||
pos := 0;
|
||||
-- Обновляем подзадачи этой родительской задачи
|
||||
FOR subtask_record IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE parent_task_id = parent_record.parent_task_id
|
||||
AND deleted = FALSE
|
||||
ORDER BY id
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET position = pos
|
||||
WHERE id = subtask_record.id;
|
||||
|
||||
pos := pos + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Создаем индекс для быстрой сортировки подзадач
|
||||
CREATE INDEX idx_tasks_parent_position ON tasks(parent_task_id, position)
|
||||
WHERE parent_task_id IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
COMMENT ON COLUMN tasks.position IS 'Position of subtask within parent task. NULL for regular tasks.';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS tracking_invite_tokens;
|
||||
DROP TABLE IF EXISTS user_tracking;
|
||||
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Таблица отслеживания между пользователями
|
||||
CREATE TABLE user_tracking (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id),
|
||||
CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id);
|
||||
CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id);
|
||||
|
||||
-- Таблица токенов приглашений (живут 1 час)
|
||||
CREATE TABLE tracking_invite_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token);
|
||||
CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id);
|
||||
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal file
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Migration: Remove group_name field from wishlist_items and tasks tables
|
||||
-- Date: 2026-02-XX
|
||||
--
|
||||
-- This migration reverses the changes made in 000014_add_group_name.up.sql
|
||||
|
||||
-- Step 1: Drop materialized view
|
||||
DROP MATERIALIZED VIEW IF EXISTS user_group_suggestions_mv;
|
||||
|
||||
-- Step 2: Drop indexes on group_name
|
||||
DROP INDEX IF EXISTS idx_tasks_group_name;
|
||||
DROP INDEX IF EXISTS idx_wishlist_items_group_name;
|
||||
|
||||
-- Step 3: Remove group_name from tasks
|
||||
ALTER TABLE tasks
|
||||
DROP COLUMN group_name;
|
||||
|
||||
-- Step 4: Add back project_id to wishlist_items
|
||||
ALTER TABLE wishlist_items
|
||||
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
-- Step 5: Try to restore project_id from group_name (if possible)
|
||||
-- Note: This is best-effort, as group_name might not match project names exactly
|
||||
UPDATE wishlist_items wi
|
||||
SET project_id = p.id
|
||||
FROM projects p
|
||||
WHERE wi.group_name = p.name
|
||||
AND wi.group_name IS NOT NULL
|
||||
AND wi.group_name != ''
|
||||
AND p.deleted = FALSE;
|
||||
|
||||
-- Step 6: Create index on project_id
|
||||
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
|
||||
|
||||
-- Step 7: Remove group_name from wishlist_items
|
||||
ALTER TABLE wishlist_items
|
||||
DROP COLUMN group_name;
|
||||
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal file
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Migration: Add group_name field to wishlist_items and tasks tables
|
||||
-- Date: 2026-02-XX
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Adds group_name field to wishlist_items (replacing project_id)
|
||||
-- 2. Migrates existing data from project_id to group_name
|
||||
-- 3. Removes project_id column from wishlist_items
|
||||
-- 4. Adds group_name field to tasks
|
||||
-- 5. Creates materialized view for group suggestions
|
||||
|
||||
-- Step 1: Add group_name to wishlist_items
|
||||
ALTER TABLE wishlist_items
|
||||
ADD COLUMN group_name VARCHAR(255);
|
||||
|
||||
-- Step 2: Migrate existing data from project_id to group_name
|
||||
UPDATE wishlist_items wi
|
||||
SET group_name = p.name
|
||||
FROM projects p
|
||||
WHERE wi.project_id = p.id AND wi.project_id IS NOT NULL;
|
||||
|
||||
-- Step 3: Remove project_id column and its index
|
||||
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
|
||||
ALTER TABLE wishlist_items
|
||||
DROP COLUMN project_id;
|
||||
|
||||
-- Step 4: Add group_name to tasks
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN group_name VARCHAR(255);
|
||||
|
||||
-- Step 5: Create indexes on group_name
|
||||
CREATE INDEX idx_wishlist_items_group_name ON wishlist_items(group_name) WHERE group_name IS NOT NULL;
|
||||
CREATE INDEX idx_tasks_group_name ON tasks(group_name) WHERE group_name IS NOT NULL;
|
||||
|
||||
-- Step 6: Create materialized view for group suggestions
|
||||
CREATE MATERIALIZED VIEW user_group_suggestions_mv AS
|
||||
SELECT DISTINCT user_id, group_name FROM (
|
||||
-- Желания пользователя (собственные)
|
||||
SELECT wi.user_id, wi.group_name FROM wishlist_items wi
|
||||
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
|
||||
UNION
|
||||
-- Желания с досок, на которых пользователь участник
|
||||
SELECT wbm.user_id, wi.group_name FROM wishlist_items wi
|
||||
JOIN wishlist_board_members wbm ON wi.board_id = wbm.board_id
|
||||
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
|
||||
UNION
|
||||
-- Задачи пользователя
|
||||
SELECT t.user_id, t.group_name FROM tasks t
|
||||
WHERE t.deleted = FALSE AND t.group_name IS NOT NULL AND t.group_name != ''
|
||||
UNION
|
||||
-- Имена проектов пользователя
|
||||
SELECT p.user_id, p.name FROM projects p
|
||||
WHERE p.deleted = FALSE
|
||||
) sub;
|
||||
|
||||
-- Step 7: Create unique index for CONCURRENT refresh
|
||||
CREATE UNIQUE INDEX idx_user_group_suggestions_mv_user_group ON user_group_suggestions_mv(user_id, group_name);
|
||||
|
||||
COMMENT ON COLUMN wishlist_items.group_name IS 'Group name for wishlist item (free text, replaces project_id)';
|
||||
COMMENT ON COLUMN tasks.group_name IS 'Group name for task (free text)';
|
||||
COMMENT ON MATERIALIZED VIEW user_group_suggestions_mv IS 'Materialized view for group name suggestions per user';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS fitbit_daily_stats;
|
||||
DROP TABLE IF EXISTS fitbit_integrations;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Fitbit integrations table (depends on users)
|
||||
CREATE TABLE fitbit_integrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
fitbit_user_id VARCHAR(255),
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
goal_steps_min INTEGER DEFAULT 8000,
|
||||
goal_steps_max INTEGER DEFAULT 10000,
|
||||
goal_floors_min INTEGER DEFAULT 8,
|
||||
goal_floors_max INTEGER DEFAULT 10,
|
||||
goal_azm_min INTEGER DEFAULT 22,
|
||||
goal_azm_max INTEGER DEFAULT 44,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fitbit_integrations_user_id_unique UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fitbit_integrations_user_id ON fitbit_integrations(user_id);
|
||||
CREATE UNIQUE INDEX idx_fitbit_integrations_fitbit_user_id ON fitbit_integrations(fitbit_user_id) WHERE fitbit_user_id IS NOT NULL;
|
||||
|
||||
-- Fitbit daily stats table (depends on users and fitbit_integrations)
|
||||
CREATE TABLE fitbit_daily_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
steps INTEGER DEFAULT 0,
|
||||
floors INTEGER DEFAULT 0,
|
||||
active_zone_minutes INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fitbit_daily_stats_user_date_unique UNIQUE (user_id, date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fitbit_daily_stats_user_id ON fitbit_daily_stats(user_id);
|
||||
CREATE INDEX idx_fitbit_daily_stats_date ON fitbit_daily_stats(date);
|
||||
CREATE INDEX idx_fitbit_daily_stats_user_date ON fitbit_daily_stats(user_id, date);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migration: Drop project_score_sample_mv materialized view
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration: Add project_score_sample_mv materialized view
|
||||
--
|
||||
-- One row per (project_id, score, user_id): sum of nodes.score per entry,
|
||||
-- representative entry_message (latest by date). Used for admin display and reporting.
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
)
|
||||
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Revert to previous MV definition (one row per project_id, score, user_id)
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
)
|
||||
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration: Make entry_message unique per (project_id, user_id) in project_score_sample_mv
|
||||
--
|
||||
-- One row per (project_id, user_id, entry_message): choose the row with latest created_date.
|
||||
-- Ensures the same entry_message does not repeat for different score values.
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
),
|
||||
with_message AS (
|
||||
SELECT
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
)
|
||||
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||
project_id,
|
||||
score,
|
||||
entry_message,
|
||||
user_id,
|
||||
created_date
|
||||
FROM with_message
|
||||
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date). entry_message is unique per project and user.';
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Revert to one row per (project_id, user_id, entry_message)
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
),
|
||||
with_message AS (
|
||||
SELECT
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
)
|
||||
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||
project_id,
|
||||
score,
|
||||
entry_message,
|
||||
user_id,
|
||||
created_date
|
||||
FROM with_message
|
||||
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date).';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Migration: One row per (project_id, user_id, score) in project_score_sample_mv
|
||||
--
|
||||
-- For each score value (per project and user) exactly one record; representative entry_message (latest by date).
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
)
|
||||
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Revert to one row per (project_id, score, user_id)
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
)
|
||||
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration: One entry_message per (project_id, user_id) in project_score_sample_mv
|
||||
--
|
||||
-- One record per score (per project, user) and one record per entry_message per project.
|
||||
-- DISTINCT ON (project_id, user_id, entry_message): same message with different scores → one row (latest by date).
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||
|
||||
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||
WITH entry_scores AS (
|
||||
SELECT
|
||||
n.project_id,
|
||||
n.entry_id,
|
||||
n.user_id,
|
||||
SUM(n.score) AS score,
|
||||
MAX(n.created_date) AS created_date
|
||||
FROM nodes n
|
||||
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||
),
|
||||
with_message AS (
|
||||
SELECT
|
||||
es.project_id,
|
||||
es.score,
|
||||
e.text AS entry_message,
|
||||
es.user_id,
|
||||
es.created_date
|
||||
FROM entry_scores es
|
||||
JOIN entries e ON e.id = es.entry_id
|
||||
)
|
||||
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||
project_id,
|
||||
score,
|
||||
entry_message,
|
||||
user_id,
|
||||
created_date
|
||||
FROM with_message
|
||||
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||
WITH DATA;
|
||||
|
||||
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): one record per score (chosen row), one entry_message per project; representative = latest by date.';
|
||||
115
play-life-backend/migrations/README.md
Normal file
115
play-life-backend/migrations/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Database Migrations
|
||||
|
||||
Этот каталог содержит SQL миграции для создания структуры базы данных проекта play-life.
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание базы данных с нуля
|
||||
|
||||
Выполните миграцию для создания всех таблиц и представлений:
|
||||
|
||||
```bash
|
||||
psql -U your_user -d your_database -f 001_create_schema.sql
|
||||
```
|
||||
|
||||
Или через docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_schema.sql
|
||||
```
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблицы
|
||||
|
||||
1. **projects** - Проекты
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `name` (VARCHAR(255) NOT NULL, UNIQUE)
|
||||
- `priority` (SMALLINT)
|
||||
|
||||
2. **entries** - Записи с текстом и датами создания
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `text` (TEXT NOT NULL)
|
||||
- `created_date` (TIMESTAMP WITH TIME ZONE NOT NULL, DEFAULT CURRENT_TIMESTAMP)
|
||||
|
||||
3. **nodes** - Узлы, связывающие проекты и записи
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||
- `entry_id` (INTEGER NOT NULL, FK -> entries.id ON DELETE CASCADE)
|
||||
- `score` (NUMERIC(8,4))
|
||||
|
||||
4. **weekly_goals** - Недельные цели для проектов
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> 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), NULL) — snapshot max на неделю (заполняется только для новых недель)
|
||||
- `priority` (SMALLINT)
|
||||
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||
|
||||
### Materialized View
|
||||
|
||||
- **weekly_report_mv** - Агрегированные данные по неделям для каждого проекта
|
||||
- `project_id` (INTEGER)
|
||||
- `report_year` (INTEGER)
|
||||
- `report_week` (INTEGER)
|
||||
- `total_score` (NUMERIC)
|
||||
- `normalized_total_score` (NUMERIC)
|
||||
|
||||
## Миграции
|
||||
|
||||
### Порядок применения миграций
|
||||
|
||||
1. **001_create_schema.sql** - Создание базовой структуры (таблицы, индексы, materialized view)
|
||||
2. **002_add_dictionaries.sql** - Добавление таблиц для словарей
|
||||
3. **003_remove_words_unique_constraint.sql** - Удаление уникального ограничения на words.name
|
||||
4. **004_add_config_dictionaries.sql** - Добавление связи между конфигурациями и словарями
|
||||
5. **005_fix_weekly_report_mv.sql** - Исправление использования ISOYEAR вместо YEAR для корректной работы на границе года
|
||||
6. **006_fix_weekly_report_mv_structure.sql** - Исправление структуры view (добавление LEFT JOIN для включения всех проектов)
|
||||
7. **026_weekly_goals_max_score.sql** - Добавление snapshot поля weekly_goals.max_score и удаление неиспользуемого actual_score
|
||||
8. **027_add_normalized_total_score_to_weekly_report_mv.sql** - Добавление normalized_total_score в weekly_report_mv (ограничение total_score по max_score)
|
||||
|
||||
### Применение миграций
|
||||
|
||||
Для существующей базы данных применяйте миграции последовательно:
|
||||
|
||||
```bash
|
||||
psql -U playeng -d playeng -f migrations/005_fix_weekly_report_mv.sql
|
||||
psql -U playeng -d playeng -f migrations/006_fix_weekly_report_mv_structure.sql
|
||||
psql -U playeng -d playeng -f migrations/026_weekly_goals_max_score.sql
|
||||
psql -U playeng -d playeng -f migrations/027_add_normalized_total_score_to_weekly_report_mv.sql
|
||||
```
|
||||
|
||||
Или через docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/005_fix_weekly_report_mv.sql
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/006_fix_weekly_report_mv_structure.sql
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/026_weekly_goals_max_score.sql
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/027_add_normalized_total_score_to_weekly_report_mv.sql
|
||||
```
|
||||
|
||||
## Обновление Materialized View
|
||||
|
||||
После изменения данных в таблицах `nodes` или `entries`, необходимо обновить materialized view:
|
||||
|
||||
```sql
|
||||
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||
```
|
||||
|
||||
## Связи между таблицами
|
||||
|
||||
- `nodes.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
- `nodes.entry_id` → `entries.id` (ON DELETE CASCADE)
|
||||
- `weekly_goals.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
|
||||
## Индексы
|
||||
|
||||
Созданы индексы для оптимизации запросов:
|
||||
- `idx_nodes_project_id` на `nodes(project_id)`
|
||||
- `idx_nodes_entry_id` на `nodes(entry_id)`
|
||||
- `idx_weekly_goals_project_id` на `weekly_goals(project_id)`
|
||||
- `idx_weekly_report_mv_project_year_week` на `weekly_report_mv(project_id, report_year, report_week)`
|
||||
|
||||
Reference in New Issue
Block a user