Первоначальный коммит

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
poignatov-home
2026-02-08 17:01:36 +03:00
commit bad198ce29
217 changed files with 57075 additions and 0 deletions

View 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

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

View File

@@ -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.

View File

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

View File

@@ -0,0 +1,3 @@
-- Rollback migration: Remove covering index for reward_configs
DROP INDEX IF EXISTS idx_reward_configs_task_id_covering;

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View File

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

View 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';

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
-- Migration: Drop projects_median_mv materialized view
-- Date: 2026-01-30
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.';

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS tracking_invite_tokens;
DROP TABLE IF EXISTS user_tracking;

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

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

View 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';

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS fitbit_daily_stats;
DROP TABLE IF EXISTS fitbit_integrations;

View File

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

View File

@@ -0,0 +1,3 @@
-- Migration: Drop project_score_sample_mv materialized view
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;

View File

@@ -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).';

View File

@@ -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).';

View File

@@ -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.';

View File

@@ -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).';

View File

@@ -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).';

View File

@@ -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).';

View File

@@ -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.';

View 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)`