Initial commit

This commit is contained in:
poignatov
2025-12-29 20:01:55 +03:00
commit 4f8a793377
63 changed files with 13655 additions and 0 deletions

34
play-life-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Environment variables with secrets
.env
# Go build artifacts
main
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,57 @@
# Multi-stage build для единого образа frontend + backend
# Stage 1: Build Frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY play-life-web/package*.json ./
RUN npm ci
# Копируем исходники (node_modules исключены через .dockerignore)
COPY play-life-web/ .
RUN npm run build
# Stage 2: Build Backend
FROM golang:1.21-alpine AS backend-builder
WORKDIR /app/backend
COPY play-life-backend/go.mod play-life-backend/go.sum ./
RUN go mod download
COPY play-life-backend/ .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 3: Final image
FROM alpine:latest
# Устанавливаем необходимые пакеты
RUN apk --no-cache add \
ca-certificates \
nginx \
supervisor \
curl
# Создаем директории
WORKDIR /app
# Копируем собранный frontend
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
# Копируем собранный backend
COPY --from=backend-builder /app/backend/main /app/backend/main
COPY play-life-backend/admin.html /app/backend/admin.html
# Копируем конфигурацию nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
# Копируем конфигурацию supervisor для запуска backend
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Создаем директории для логов
RUN mkdir -p /var/log/supervisor && \
mkdir -p /var/log/nginx && \
mkdir -p /var/run
# Открываем порт 80
EXPOSE 80
# Запускаем supervisor, который запустит nginx и backend
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -0,0 +1,76 @@
# Настройка переменных окружения
## Быстрый старт
1. Скопируйте файл `env.example` в `.env`:
```bash
cp env.example .env
```
2. Откройте `.env` и заполните реальные значения:
```bash
nano .env
# или
vim .env
```
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
## Переменные окружения
### Обязательные (для работы приложения)
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
- `DB_USER` - пользователь БД (по умолчанию: playeng)
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
- `DB_NAME` - имя БД (по умолчанию: playeng)
- `PORT` - порт сервера (по умолчанию: 8080)
### Опциональные (для Telegram интеграции)
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
## Использование в коде
Приложение автоматически читает переменные окружения через `os.Getenv()`.
Для загрузки `.env` файла в локальной разработке можно использовать:
### Вариант 1: Установить переменные вручную
```bash
export DB_PASSWORD=your_password
export TELEGRAM_BOT_TOKEN=your_token
go run main.go
```
### Вариант 2: Использовать библиотеку godotenv (рекомендуется)
1. Установить библиотеку:
```bash
go get github.com/joho/godotenv
```
2. Добавить в начало `main()`:
```go
import "github.com/joho/godotenv"
func main() {
// Загрузить .env файл
godotenv.Load()
// ... остальной код
}
```
### Вариант 3: Использовать docker-compose
В `docker-compose.yml` уже настроена передача переменных окружения из `.env` файла.
## Безопасность
- ✅ Файл `.env` добавлен в `.gitignore`
- ✅ Файл `env.example` содержит только шаблоны без реальных значений
- ✅ Никогда не коммитьте `.env` в git
- ✅ Используйте разные токены для dev/prod окружений

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Play Life Backend - Admin Panel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 30px;
font-size: 2.5em;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.3em;
display: flex;
align-items: center;
gap: 10px;
}
.card textarea {
width: 100%;
min-height: 150px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 14px;
font-family: monospace;
resize: vertical;
margin-bottom: 15px;
}
.card textarea:focus {
outline: none;
border-color: #667eea;
}
.card button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}
.card button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.card button:active {
transform: translateY(0);
}
.card button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.result {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.result h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.1em;
}
.result pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.result.success {
border-left-color: #4caf50;
}
.result.error {
border-left-color: #f44336;
}
.result.loading {
border-left-color: #ff9800;
}
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.status.success {
background: #4caf50;
color: white;
}
.status.error {
background: #f44336;
color: white;
}
.status.loading {
background: #ff9800;
color: white;
}
</style>
</head>
<body>
<div class="container">
<h1>🎯 Play Life Backend - Admin Panel</h1>
<div class="grid">
<!-- Message Post Card -->
<div class="card">
<h2>
📨 Message Post
<span class="status" id="messageStatus" style="display: none;"></span>
</h2>
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
Пример:
Сегодня работал над проектами:
**Frontend+15.5**
**Backend+8.0**
**Design-2.5**"></textarea>
<button onclick="sendMessage()">Отправить сообщение</button>
<div id="messageResult"></div>
</div>
<!-- Daily Report Trigger Card -->
<div class="card">
<h2>
📈 Daily Report Trigger
<span class="status" id="dailyReportStatus" style="display: none;"></span>
</h2>
<p style="margin-bottom: 15px; color: #666;">
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59).
</p>
<button onclick="triggerDailyReport()">Отправить отчёт</button>
<div id="dailyReportResult"></div>
</div>
<!-- Weekly Goals Setup Card -->
<div class="card">
<h2>
🎯 Weekly Goals Setup
<span class="status" id="goalsStatus" style="display: none;"></span>
</h2>
<p style="margin-bottom: 15px; color: #666;">
Нажмите кнопку для установки целей на текущую неделю на основе медианы за последние 3 месяца (с отправкой в чат). Обычно срабатывает автоматически в начале недели.
</p>
<button onclick="setupWeeklyGoals()">Обновить цели</button>
<div id="goalsResult"></div>
</div>
</div>
</div>
<script>
function getApiUrl() {
// Автоматически определяем URL текущего хоста
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
return window.location.origin;
}
function showStatus(elementId, status, text) {
const statusEl = document.getElementById(elementId);
statusEl.textContent = text;
statusEl.className = `status ${status}`;
statusEl.style.display = 'inline-block';
}
function hideStatus(elementId) {
document.getElementById(elementId).style.display = 'none';
}
function showResult(elementId, data, isError = false, isLoading = false) {
const resultEl = document.getElementById(elementId);
resultEl.innerHTML = '';
if (isLoading) {
resultEl.innerHTML = '<div class="result loading"><h3>⏳ Загрузка...</h3></div>';
return;
}
const div = document.createElement('div');
div.className = `result ${isError ? 'error' : 'success'}`;
const h3 = document.createElement('h3');
h3.textContent = isError ? '❌ Ошибка' : '✅ Успешно';
div.appendChild(h3);
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(data, null, 2);
div.appendChild(pre);
resultEl.appendChild(div);
}
async function sendMessage() {
const text = document.getElementById('messageText').value.trim();
if (!text) {
alert('Пожалуйста, введите сообщение');
return;
}
showStatus('messageStatus', 'loading', 'Отправка...');
showResult('messageResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/message/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
body: {
text: text
}
})
});
const data = await response.json();
if (response.ok) {
showStatus('messageStatus', 'success', 'Успешно');
showResult('messageResult', data, false);
} else {
showStatus('messageStatus', 'error', 'Ошибка');
showResult('messageResult', data, true);
}
} catch (error) {
showStatus('messageStatus', 'error', 'Ошибка');
showResult('messageResult', { error: error.message }, true);
}
}
async function setupWeeklyGoals() {
showStatus('goalsStatus', 'loading', 'Обновление...');
showResult('goalsResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (response.ok) {
showStatus('goalsStatus', 'success', 'Успешно');
showResult('goalsResult', data, false);
} else {
showStatus('goalsStatus', 'error', 'Ошибка');
showResult('goalsResult', data, true);
}
} catch (error) {
showStatus('goalsStatus', 'error', 'Ошибка');
showResult('goalsResult', { error: error.message }, true);
}
}
async function triggerDailyReport() {
showStatus('dailyReportStatus', 'loading', 'Отправка...');
showResult('dailyReportResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (response.ok) {
showStatus('dailyReportStatus', 'success', 'Успешно');
showResult('dailyReportResult', data, false);
} else {
showStatus('dailyReportStatus', 'error', 'Ошибка');
showResult('dailyReportResult', data, true);
}
} catch (error) {
showStatus('dailyReportStatus', 'error', 'Ошибка');
showResult('dailyReportResult', { error: error.message }, true);
}
}
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
document.getElementById('messageText').addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-playeng}
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
POSTGRES_DB: ${DB_NAME:-playeng}
ports:
- "${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
interval: 10s
timeout: 5s
retries: 5
env_file:
- ../.env
- .env # Локальный .env имеет приоритет
backend:
build: .
ports:
- "${PORT:-8080}:8080"
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: ${DB_USER:-playeng}
DB_PASSWORD: ${DB_PASSWORD:-playeng}
DB_NAME: ${DB_NAME:-playeng}
PORT: ${PORT:-8080}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
depends_on:
db:
condition: service_healthy
volumes:
- ./migrations:/migrations
env_file:
- ../.env
- .env # Локальный .env имеет приоритет

View File

@@ -0,0 +1,17 @@
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=playeng
DB_PASSWORD=playeng
DB_NAME=playeng
# Server Configuration
PORT=8080
# Telegram Bot Configuration (optional - for direct Telegram integration)
# Get token from @BotFather in Telegram: https://t.me/botfather
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
# Look for "chat":{"id":123456789} - that number is your chat ID
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHAT_ID=123456789

14
play-life-backend/go.mod Normal file
View File

@@ -0,0 +1,14 @@
module play-eng-backend
go 1.21
require (
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/robfig/cron/v3 v3.0.1
)
require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
)

8
play-life-backend/go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=

3672
play-life-backend/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
-- Migration: Create database schema for play-life project
-- This script creates all tables and materialized views needed for the project
-- ============================================
-- Table: projects
-- ============================================
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
priority SMALLINT,
CONSTRAINT unique_project_name UNIQUE (name)
);
-- ============================================
-- Table: entries
-- ============================================
-- This table stores entries with creation dates
-- Used in weekly_report_mv for grouping by week
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ============================================
-- Table: nodes
-- ============================================
-- This table stores nodes linked to projects and entries
-- Contains score information used in weekly reports
CREATE TABLE IF NOT EXISTS 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)
);
-- Create index on project_id for better join performance
CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id);
-- Create index on entry_id for better join performance
CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id);
-- ============================================
-- Table: weekly_goals
-- ============================================
-- This table stores weekly goals for projects
CREATE TABLE IF NOT EXISTS 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),
actual_score NUMERIC(10,4) DEFAULT 0,
priority SMALLINT,
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
);
-- Create index on project_id for better join performance
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id);
-- ============================================
-- Materialized View: weekly_report_mv
-- ============================================
CREATE MATERIALIZED VIEW IF NOT EXISTS weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных (NULL)
COALESCE(agg.total_score, 0.0000) AS total_score
FROM
projects p
LEFT JOIN
(
-- 1. Предварительная агрегация: суммируем score по неделям
SELECT
n.project_id,
EXTRACT(YEAR 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
-- 2. Присоединяем агрегированные данные ко ВСЕМ проектам
ON p.id = agg.project_id
ORDER BY
p.id, agg.report_year, agg.report_week;
-- Create index on materialized view for better query performance
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE projects IS 'Projects table storing project information with priority';
COMMENT ON TABLE entries IS 'Entries table storing entry creation timestamps';
COMMENT ON TABLE nodes IS 'Nodes table linking projects, entries and storing scores';
COMMENT ON TABLE weekly_goals IS 'Weekly goals for projects';
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project';

View File

@@ -0,0 +1,53 @@
-- Migration: Add dictionaries table and dictionary_id to words
-- This script creates the dictionaries table and adds dictionary_id field to words table
-- ============================================
-- Table: dictionaries
-- ============================================
CREATE TABLE IF NOT EXISTS dictionaries (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
-- Insert default dictionary "Все слова" with id = 0
-- Note: PostgreSQL SERIAL starts from 1, so we need to use a workaround
-- First, set the sequence to allow inserting 0, then insert, then reset sequence
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);
END $$;
-- ============================================
-- Alter words table: Add dictionary_id column
-- ============================================
ALTER TABLE words
ADD COLUMN IF NOT EXISTS dictionary_id INTEGER DEFAULT 0 REFERENCES dictionaries(id);
-- Update all existing words to have dictionary_id = 0
UPDATE words
SET dictionary_id = 0
WHERE dictionary_id IS NULL;
-- Make dictionary_id NOT NULL after setting default values
ALTER TABLE words
ALTER COLUMN dictionary_id SET NOT NULL,
ALTER COLUMN dictionary_id SET DEFAULT 0;
-- Create index on dictionary_id for better join performance
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id);
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE dictionaries IS 'Dictionaries table storing dictionary information';
COMMENT ON COLUMN words.dictionary_id IS 'Reference to dictionary. Default is 0 (Все слова)';

View File

@@ -0,0 +1,11 @@
-- Migration: Remove UNIQUE constraint from words.name
-- This script removes the unique constraint on the name column in the words table
-- Drop the unique constraint on words.name if it exists
ALTER TABLE words
DROP CONSTRAINT IF EXISTS words_name_key;
-- Also try to drop constraint if it was created with different name
ALTER TABLE words
DROP CONSTRAINT IF EXISTS words_name_unique;

View File

@@ -0,0 +1,21 @@
-- Migration: Add config_dictionaries table (many-to-many relationship)
-- This script creates the config_dictionaries table linking configs and dictionaries
-- ============================================
-- Table: config_dictionaries
-- ============================================
CREATE TABLE IF NOT EXISTS 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 indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id);
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE config_dictionaries IS 'Many-to-many relationship table linking configs and dictionaries. If no dictionaries are selected for a config, all dictionaries will be used.';

View File

@@ -0,0 +1,29 @@
-- Migration: Fix weekly_report_mv to use ISOYEAR instead of YEAR
-- This fixes incorrect week calculations at year boundaries
-- Date: 2024
-- Drop existing materialized view
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
-- Recreate materialized view with ISOYEAR
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
n.project_id,
-- 🔑 ГЛАВНОЕ ИСПРАВЛЕНИЕ: Используем ISOYEAR
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
WITH DATA;
-- Recreate index
CREATE INDEX IF NOT EXISTS 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';

View File

@@ -0,0 +1,81 @@
# 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))
- `actual_score` (NUMERIC(10,4), DEFAULT 0)
- `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)
## Обновление 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)`

View File

@@ -0,0 +1,20 @@
#!/bin/bash
cd "$(dirname "$0")"
# Настройки подключения к БД (можно изменить через переменные окружения)
export DB_HOST=${DB_HOST:-localhost}
export DB_PORT=${DB_PORT:-5432}
export DB_USER=${DB_USER:-postgres}
export DB_PASSWORD=${DB_PASSWORD:-postgres}
export DB_NAME=${DB_NAME:-playlife}
export PORT=${PORT:-8080}
echo "Starting backend server..."
echo "DB_HOST: $DB_HOST"
echo "DB_PORT: $DB_PORT"
echo "DB_USER: $DB_USER"
echo "DB_NAME: $DB_NAME"
echo "PORT: $PORT"
echo ""
go run main.go