initial commit

This commit is contained in:
2025-11-29 00:28:21 +05:00
parent 46229acc82
commit ec3b03a935
76 changed files with 13492 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
-- +goose Up
-- +goose StatementBegin
-- Включение расширения PostGIS для работы с геолокацией
CREATE EXTENSION IF NOT EXISTS postgis;
-- Включение расширения для генерации UUID (на случай будущего использования)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Включение расширения для нечеткого поиска текста
CREATE EXTENSION IF NOT EXISTS pg_trgm;
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions';
COMMENT ON EXTENSION pg_trgm IS 'Trigram similarity and distance functions for text search';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP EXTENSION IF EXISTS pg_trgm;
DROP EXTENSION IF EXISTS "uuid-ossp";
DROP EXTENSION IF EXISTS postgis CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,56 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТАБЛИЦА: roles - Роли пользователей
-- =========================================
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE roles IS 'Справочник ролей для RBAC системы';
COMMENT ON COLUMN roles.name IS 'Уникальное название роли';
-- =========================================
-- ТАБЛИЦА: permissions - Разрешения системы
-- =========================================
CREATE TABLE permissions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
resource VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE permissions IS 'Справочник разрешений для RBAC системы';
COMMENT ON COLUMN permissions.resource IS 'Ресурс: request, user, complaint и т.д.';
COMMENT ON COLUMN permissions.action IS 'Действие: create, read, update, delete, moderate';
-- =========================================
-- ТАБЛИЦА: request_types - Типы заявок на помощь
-- =========================================
CREATE TABLE request_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
icon VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE request_types IS 'Справочник типов помощи (продукты, медикаменты, техника)';
COMMENT ON COLUMN request_types.icon IS 'Название иконки для UI';
COMMENT ON COLUMN request_types.is_active IS 'Активность типа (для скрытия без удаления)';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS request_types CASCADE;
DROP TABLE IF EXISTS permissions CASCADE;
DROP TABLE IF EXISTS roles CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,59 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТАБЛИЦА: users - Пользователи системы
-- =========================================
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
-- Аутентификация
email VARCHAR(255) NOT NULL UNIQUE,
phone VARCHAR(20),
password_hash VARCHAR(255) NOT NULL,
-- Профиль
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
avatar_url TEXT,
-- Геолокация (домашний адрес)
location GEOGRAPHY(POINT, 4326),
address TEXT,
city VARCHAR(100),
-- Статистика для волонтёров (денормализация для производительности)
volunteer_rating NUMERIC(3, 2) DEFAULT 0.00 CHECK (volunteer_rating >= 0 AND volunteer_rating <= 5),
completed_requests_count INTEGER DEFAULT 0 CHECK (completed_requests_count >= 0),
-- Статусы
is_verified BOOLEAN DEFAULT FALSE,
is_blocked BOOLEAN DEFAULT FALSE,
email_verified BOOLEAN DEFAULT FALSE,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Комментарии
COMMENT ON TABLE users IS 'Пользователи системы: маломобильные граждане, волонтёры, модераторы';
COMMENT ON COLUMN users.location IS 'Координаты домашнего адреса в формате WGS84 (SRID 4326)';
COMMENT ON COLUMN users.volunteer_rating IS 'Средний рейтинг волонтёра (0-5), обновляется триггером';
COMMENT ON COLUMN users.completed_requests_count IS 'Количество выполненных заявок, обновляется триггером';
COMMENT ON COLUMN users.deleted_at IS 'Soft delete - дата удаления пользователя';
-- Индексы
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_phone ON users(phone) WHERE phone IS NOT NULL;
CREATE INDEX idx_users_is_blocked ON users(is_blocked) WHERE is_blocked = TRUE;
CREATE INDEX idx_users_deleted_at ON users(deleted_at) WHERE deleted_at IS NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS users CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,44 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТАБЛИЦА: user_roles - Связь пользователей и ролей (Many-to-Many)
-- =========================================
CREATE TABLE user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
assigned_by BIGINT REFERENCES users(id),
UNIQUE(user_id, role_id)
);
COMMENT ON TABLE user_roles IS 'Связь пользователей и ролей (Many-to-Many). Один пользователь может иметь несколько ролей';
COMMENT ON COLUMN user_roles.assigned_by IS 'Кто назначил роль (для аудита)';
-- =========================================
-- ТАБЛИЦА: role_permissions - Связь ролей и разрешений (Many-to-Many)
-- =========================================
CREATE TABLE role_permissions (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(role_id, permission_id)
);
COMMENT ON TABLE role_permissions IS 'Связь ролей и разрешений (Many-to-Many) для гибкой системы RBAC';
-- Индексы для оптимизации запросов прав доступа
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
CREATE INDEX idx_role_permissions_permission_id ON role_permissions(permission_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS role_permissions CASCADE;
DROP TABLE IF EXISTS user_roles CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,103 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ENUM: request_status - Статусы заявки
-- =========================================
CREATE TYPE request_status AS ENUM (
'pending_moderation', -- На модерации
'approved', -- Одобрена, ожидает отклика волонтёра
'in_progress', -- Взята волонтёром в работу
'completed', -- Успешно выполнена
'cancelled', -- Отменена заявителем
'rejected' -- Отклонена модератором
);
COMMENT ON TYPE request_status IS 'Статусы жизненного цикла заявки на помощь';
-- =========================================
-- ТАБЛИЦА: requests - Заявки на помощь
-- =========================================
CREATE TABLE requests (
id BIGSERIAL PRIMARY KEY,
-- Связи
requester_id BIGINT NOT NULL REFERENCES users(id),
request_type_id BIGINT NOT NULL REFERENCES request_types(id),
assigned_volunteer_id BIGINT REFERENCES users(id),
-- Основная информация
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
-- Геолокация (обязательное поле для геопоиска)
location GEOGRAPHY(POINT, 4326) NOT NULL,
address TEXT NOT NULL,
city VARCHAR(100),
-- Детали
desired_completion_date TIMESTAMP WITH TIME ZONE,
urgency VARCHAR(20) DEFAULT 'medium' CHECK (urgency IN ('low', 'medium', 'high', 'urgent')),
-- Статус и модерация
status request_status DEFAULT 'pending_moderation',
moderation_comment TEXT,
moderated_by BIGINT REFERENCES users(id),
moderated_at TIMESTAMP WITH TIME ZONE,
-- Контактная информация
contact_phone VARCHAR(20),
contact_notes TEXT,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP WITH TIME ZONE,
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Комментарии
COMMENT ON TABLE requests IS 'Заявки на помощь от маломобильных граждан';
COMMENT ON COLUMN requests.location IS 'Координаты места, где нужна помощь (WGS84, SRID 4326)';
COMMENT ON COLUMN requests.urgency IS 'Срочность: low, medium, high, urgent';
COMMENT ON COLUMN requests.assigned_volunteer_id IS 'Волонтёр, который взял заявку в работу';
COMMENT ON COLUMN requests.contact_notes IS 'Дополнительная информация: код домофона, этаж и т.д.';
COMMENT ON COLUMN requests.deleted_at IS 'Soft delete - дата удаления заявки';
-- Индексы
CREATE INDEX idx_requests_requester_id ON requests(requester_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_requests_assigned_volunteer_id ON requests(assigned_volunteer_id) WHERE assigned_volunteer_id IS NOT NULL;
CREATE INDEX idx_requests_status ON requests(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_requests_type_id ON requests(request_type_id);
CREATE INDEX idx_requests_created_at ON requests(created_at DESC);
CREATE INDEX idx_requests_urgency ON requests(urgency) WHERE deleted_at IS NULL;
CREATE INDEX idx_requests_deleted_at ON requests(deleted_at) WHERE deleted_at IS NULL;
-- =========================================
-- ТАБЛИЦА: request_status_history - История изменения статусов
-- =========================================
CREATE TABLE request_status_history (
id BIGSERIAL PRIMARY KEY,
request_id BIGINT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
from_status request_status,
to_status request_status NOT NULL,
changed_by BIGINT NOT NULL REFERENCES users(id),
comment TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE request_status_history IS 'Полная история изменения статусов заявок для аудита';
COMMENT ON COLUMN request_status_history.from_status IS 'Предыдущий статус (NULL при создании)';
-- Индекс для быстрого получения истории по заявке
CREATE INDEX idx_request_status_history_request_id ON request_status_history(request_id);
CREATE INDEX idx_request_status_history_created_at ON request_status_history(created_at DESC);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS request_status_history CASCADE;
DROP TABLE IF EXISTS requests CASCADE;
DROP TYPE IF EXISTS request_status CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,60 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ENUM: response_status - Статусы отклика
-- =========================================
CREATE TYPE response_status AS ENUM (
'pending', -- Ожидает рассмотрения заявителем
'accepted', -- Принят (волонтёр взял заявку)
'rejected', -- Отклонён заявителем
'cancelled' -- Отменён волонтёром
);
COMMENT ON TYPE response_status IS 'Статусы отклика волонтёра на заявку';
-- =========================================
-- ТАБЛИЦА: volunteer_responses - Отклики волонтёров на заявки
-- =========================================
CREATE TABLE volunteer_responses (
id BIGSERIAL PRIMARY KEY,
-- Связи
request_id BIGINT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
volunteer_id BIGINT NOT NULL REFERENCES users(id),
-- Статус и сообщение
status response_status DEFAULT 'pending',
message TEXT,
-- Временные метки
responded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP WITH TIME ZONE,
rejected_at TIMESTAMP WITH TIME ZONE,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Ограничение: один волонтёр может откликнуться на заявку только один раз
UNIQUE(request_id, volunteer_id)
);
-- Комментарии
COMMENT ON TABLE volunteer_responses IS 'Отклики волонтёров на заявки помощи';
COMMENT ON COLUMN volunteer_responses.message IS 'Сообщение волонтёра при отклике (опционально)';
COMMENT ON COLUMN volunteer_responses.responded_at IS 'Время создания отклика';
-- Индексы
CREATE INDEX idx_volunteer_responses_request_id ON volunteer_responses(request_id);
CREATE INDEX idx_volunteer_responses_volunteer_id ON volunteer_responses(volunteer_id);
CREATE INDEX idx_volunteer_responses_status ON volunteer_responses(status);
CREATE INDEX idx_volunteer_responses_volunteer_status ON volunteer_responses(volunteer_id, status);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS volunteer_responses CASCADE;
DROP TYPE IF EXISTS response_status CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,43 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТАБЛИЦА: ratings - Рейтинги волонтёров
-- =========================================
CREATE TABLE ratings (
id BIGSERIAL PRIMARY KEY,
-- Связи
volunteer_response_id BIGINT NOT NULL UNIQUE REFERENCES volunteer_responses(id),
volunteer_id BIGINT NOT NULL REFERENCES users(id),
requester_id BIGINT NOT NULL REFERENCES users(id),
request_id BIGINT NOT NULL REFERENCES requests(id),
-- Оценка
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment TEXT,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Комментарии
COMMENT ON TABLE ratings IS 'Рейтинги волонтёров за выполненную помощь';
COMMENT ON COLUMN ratings.rating IS 'Оценка от 1 до 5 звёзд';
COMMENT ON COLUMN ratings.volunteer_response_id IS 'Связь с откликом (один рейтинг на один отклик)';
COMMENT ON COLUMN ratings.volunteer_id IS 'Денормализация для быстрого доступа';
COMMENT ON COLUMN ratings.requester_id IS 'Кто оставил рейтинг';
-- Индексы
CREATE INDEX idx_ratings_volunteer_id ON ratings(volunteer_id);
CREATE INDEX idx_ratings_request_id ON ratings(request_id);
CREATE INDEX idx_ratings_requester_id ON ratings(requester_id);
CREATE INDEX idx_ratings_created_at ON ratings(created_at DESC);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS ratings CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,110 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ENUM: complaint_status - Статусы жалобы
-- =========================================
CREATE TYPE complaint_status AS ENUM (
'pending', -- Ожидает рассмотрения
'in_review', -- На рассмотрении модератором
'resolved', -- Разрешена
'rejected' -- Отклонена
);
COMMENT ON TYPE complaint_status IS 'Статусы жизненного цикла жалобы';
-- =========================================
-- ENUM: complaint_type - Типы жалоб
-- =========================================
CREATE TYPE complaint_type AS ENUM (
'inappropriate_behavior', -- Неподобающее поведение
'no_show', -- Не явился
'fraud', -- Мошенничество
'spam', -- Спам
'other' -- Другое
);
COMMENT ON TYPE complaint_type IS 'Типы жалоб на пользователей';
-- =========================================
-- ТАБЛИЦА: complaints - Жалобы
-- =========================================
CREATE TABLE complaints (
id BIGSERIAL PRIMARY KEY,
-- Связи
complainant_id BIGINT NOT NULL REFERENCES users(id), -- Кто жалуется
defendant_id BIGINT NOT NULL REFERENCES users(id), -- На кого жалуются
request_id BIGINT REFERENCES requests(id), -- Связанная заявка (опционально)
-- Содержание жалобы
type complaint_type NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
-- Статус и обработка
status complaint_status DEFAULT 'pending',
moderator_id BIGINT REFERENCES users(id),
moderator_comment TEXT,
resolved_at TIMESTAMP WITH TIME ZONE,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Комментарии
COMMENT ON TABLE complaints IS 'Жалобы пользователей друг на друга';
COMMENT ON COLUMN complaints.complainant_id IS 'Пользователь, подающий жалобу';
COMMENT ON COLUMN complaints.defendant_id IS 'Пользователь, на которого жалуются';
-- Индексы
CREATE INDEX idx_complaints_defendant_id ON complaints(defendant_id);
CREATE INDEX idx_complaints_complainant_id ON complaints(complainant_id);
CREATE INDEX idx_complaints_status ON complaints(status);
CREATE INDEX idx_complaints_type ON complaints(type);
CREATE INDEX idx_complaints_moderator_id ON complaints(moderator_id) WHERE moderator_id IS NOT NULL;
-- =========================================
-- ТАБЛИЦА: user_blocks - Блокировки пользователей
-- =========================================
CREATE TABLE user_blocks (
id BIGSERIAL PRIMARY KEY,
-- Связи
user_id BIGINT NOT NULL REFERENCES users(id),
blocked_by BIGINT NOT NULL REFERENCES users(id),
complaint_id BIGINT REFERENCES complaints(id), -- Связанная жалоба (опционально)
-- Детали блокировки
reason TEXT NOT NULL,
blocked_until TIMESTAMP WITH TIME ZONE, -- NULL = бессрочная блокировка
-- Статус
is_active BOOLEAN DEFAULT TRUE,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
unblocked_at TIMESTAMP WITH TIME ZONE,
unblocked_by BIGINT REFERENCES users(id)
);
-- Комментарии
COMMENT ON TABLE user_blocks IS 'Блокировки пользователей модераторами';
COMMENT ON COLUMN user_blocks.blocked_until IS 'Дата окончания блокировки (NULL = бессрочная)';
COMMENT ON COLUMN user_blocks.is_active IS 'Активна ли блокировка в данный момент';
-- Индексы
CREATE INDEX idx_user_blocks_user_id_active ON user_blocks(user_id, is_active) WHERE is_active = TRUE;
CREATE INDEX idx_user_blocks_blocked_by ON user_blocks(blocked_by);
CREATE INDEX idx_user_blocks_created_at ON user_blocks(created_at DESC);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS user_blocks CASCADE;
DROP TABLE IF EXISTS complaints CASCADE;
DROP TYPE IF EXISTS complaint_type CASCADE;
DROP TYPE IF EXISTS complaint_status CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,60 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ENUM: moderator_action_type - Типы действий модератора
-- =========================================
CREATE TYPE moderator_action_type AS ENUM (
'approve_request', -- Одобрение заявки
'reject_request', -- Отклонение заявки
'block_user', -- Блокировка пользователя
'unblock_user', -- Разблокировка пользователя
'resolve_complaint', -- Разрешение жалобы
'reject_complaint', -- Отклонение жалобы
'edit_request', -- Редактирование заявки
'delete_request' -- Удаление заявки
);
COMMENT ON TYPE moderator_action_type IS 'Типы действий модераторов для аудита';
-- =========================================
-- ТАБЛИЦА: moderator_actions - Логи действий модераторов
-- =========================================
CREATE TABLE moderator_actions (
id BIGSERIAL PRIMARY KEY,
-- Модератор
moderator_id BIGINT NOT NULL REFERENCES users(id),
action_type moderator_action_type NOT NULL,
-- Целевые объекты (опционально, зависит от типа действия)
target_user_id BIGINT REFERENCES users(id),
target_request_id BIGINT REFERENCES requests(id),
target_complaint_id BIGINT REFERENCES complaints(id),
-- Детали действия
comment TEXT,
metadata JSONB, -- Дополнительные данные в формате JSON
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Комментарии
COMMENT ON TABLE moderator_actions IS 'Полный аудит всех действий модераторов в системе';
COMMENT ON COLUMN moderator_actions.metadata IS 'Дополнительные данные в JSON (изменённые поля, причины и т.д.)';
-- Индексы
CREATE INDEX idx_moderator_actions_moderator_id ON moderator_actions(moderator_id);
CREATE INDEX idx_moderator_actions_action_type ON moderator_actions(action_type);
CREATE INDEX idx_moderator_actions_created_at ON moderator_actions(created_at DESC);
CREATE INDEX idx_moderator_actions_target_user_id ON moderator_actions(target_user_id) WHERE target_user_id IS NOT NULL;
CREATE INDEX idx_moderator_actions_target_request_id ON moderator_actions(target_request_id) WHERE target_request_id IS NOT NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS moderator_actions CASCADE;
DROP TYPE IF EXISTS moderator_action_type CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,82 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТАБЛИЦА: refresh_tokens - JWT Refresh токены
-- =========================================
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
-- Пользователь
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Токен
token VARCHAR(512) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
-- Метаданные запроса
user_agent TEXT,
ip_address INET,
-- Отзыв токена
revoked BOOLEAN DEFAULT FALSE,
revoked_at TIMESTAMP WITH TIME ZONE,
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Комментарии
COMMENT ON TABLE refresh_tokens IS 'Refresh токены для JWT аутентификации';
COMMENT ON COLUMN refresh_tokens.token IS 'Хеш refresh токена';
COMMENT ON COLUMN refresh_tokens.revoked IS 'Токен отозван (для принудительного логаута)';
-- Индексы
CREATE UNIQUE INDEX idx_refresh_tokens_token ON refresh_tokens(token) WHERE revoked = FALSE;
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id) WHERE revoked = FALSE;
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
-- =========================================
-- ТАБЛИЦА: user_sessions - Активные сессии пользователей
-- =========================================
CREATE TABLE user_sessions (
id BIGSERIAL PRIMARY KEY,
-- Пользователь
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(512) NOT NULL UNIQUE,
-- Связь с refresh токеном
refresh_token_id BIGINT REFERENCES refresh_tokens(id),
-- Сессия
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Метаданные
user_agent TEXT,
ip_address INET,
device_info JSONB, -- Информация об устройстве в JSON
-- Аудит
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Комментарии
COMMENT ON TABLE user_sessions IS 'Активные сессии пользователей для отслеживания активности';
COMMENT ON COLUMN user_sessions.device_info IS 'Информация об устройстве: ОС, браузер, версия и т.д.';
COMMENT ON COLUMN user_sessions.last_activity_at IS 'Последняя активность пользователя в сессии';
-- Индексы
CREATE UNIQUE INDEX idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
CREATE INDEX idx_user_sessions_last_activity ON user_sessions(last_activity_at DESC);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS user_sessions CASCADE;
DROP TABLE IF EXISTS refresh_tokens CASCADE;
-- +goose StatementEnd

View File

@@ -0,0 +1,58 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ДОПОЛНИТЕЛЬНЫЕ ИНДЕКСЫ ДЛЯ ОПТИМИЗАЦИИ
-- =========================================
-- Индексы для users
CREATE INDEX idx_users_volunteer_rating ON users(volunteer_rating DESC) WHERE volunteer_rating > 0 AND deleted_at IS NULL;
CREATE INDEX idx_users_completed_requests ON users(completed_requests_count DESC) WHERE completed_requests_count > 0 AND deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON users(created_at DESC);
-- Составные индексы для requests (для сложных запросов)
CREATE INDEX idx_requests_status_created ON requests(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_requests_requester_status ON requests(requester_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_requests_volunteer_status ON requests(assigned_volunteer_id, status) WHERE assigned_volunteer_id IS NOT NULL;
CREATE INDEX idx_requests_type_status ON requests(request_type_id, status) WHERE deleted_at IS NULL;
-- Индексы для volunteer_responses
CREATE INDEX idx_volunteer_responses_created_at ON volunteer_responses(created_at DESC);
CREATE INDEX idx_volunteer_responses_request_status ON volunteer_responses(request_id, status);
-- Индексы для ratings
CREATE INDEX idx_ratings_volunteer_rating ON ratings(volunteer_id, rating DESC);
-- Индексы для complaints
CREATE INDEX idx_complaints_status_created ON complaints(status, created_at DESC);
CREATE INDEX idx_complaints_defendant_status ON complaints(defendant_id, status);
-- Индексы для user_blocks
CREATE INDEX idx_user_blocks_blocked_until ON user_blocks(blocked_until) WHERE is_active = TRUE AND blocked_until IS NOT NULL;
-- Полнотекстовый поиск для requests
CREATE INDEX idx_requests_title_trgm ON requests USING gin(title gin_trgm_ops);
CREATE INDEX idx_requests_description_trgm ON requests USING gin(description gin_trgm_ops);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_requests_description_trgm;
DROP INDEX IF EXISTS idx_requests_title_trgm;
DROP INDEX IF EXISTS idx_user_blocks_blocked_until;
DROP INDEX IF EXISTS idx_complaints_defendant_status;
DROP INDEX IF EXISTS idx_complaints_status_created;
DROP INDEX IF EXISTS idx_ratings_volunteer_rating;
DROP INDEX IF EXISTS idx_volunteer_responses_request_status;
DROP INDEX IF EXISTS idx_volunteer_responses_created_at;
DROP INDEX IF EXISTS idx_requests_type_status;
DROP INDEX IF EXISTS idx_requests_volunteer_status;
DROP INDEX IF EXISTS idx_requests_requester_status;
DROP INDEX IF EXISTS idx_requests_status_created;
DROP INDEX IF EXISTS idx_users_created_at;
DROP INDEX IF EXISTS idx_users_completed_requests;
DROP INDEX IF EXISTS idx_users_volunteer_rating;
-- +goose StatementEnd

View File

@@ -0,0 +1,43 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- КРИТИЧЕСКИЕ GIST ИНДЕКСЫ ДЛЯ ГЕОПОИСКА
-- =========================================
-- GIST индекс для геолокации пользователей (волонтёров)
-- Используется для поиска волонтёров рядом с заявкой
CREATE INDEX idx_users_location_gist ON users USING GIST(location)
WHERE location IS NOT NULL AND deleted_at IS NULL;
-- GIST индекс для геолокации заявок
-- Используется для поиска заявок рядом с волонтёром
CREATE INDEX idx_requests_location_gist ON requests USING GIST(location)
WHERE deleted_at IS NULL;
-- Составной GIST индекс для геолокации + статус заявки
-- Критично для алгоритма матчинга: поиск только одобренных заявок рядом
CREATE INDEX idx_requests_location_status_gist ON requests USING GIST(location)
WHERE status = 'approved' AND deleted_at IS NULL;
-- GIST индекс для геолокации активных заявок
-- Используется для поиска заявок, готовых к выполнению
CREATE INDEX idx_requests_location_active_gist ON requests USING GIST(location)
WHERE status IN ('approved', 'in_progress') AND deleted_at IS NULL;
COMMENT ON INDEX idx_users_location_gist IS 'GIST индекс для быстрого геопоиска волонтёров';
COMMENT ON INDEX idx_requests_location_gist IS 'GIST индекс для быстрого геопоиска всех заявок';
COMMENT ON INDEX idx_requests_location_status_gist IS 'GIST индекс для поиска одобренных заявок (алгоритм матчинга)';
COMMENT ON INDEX idx_requests_location_active_gist IS 'GIST индекс для поиска активных заявок';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_requests_location_active_gist;
DROP INDEX IF EXISTS idx_requests_location_status_gist;
DROP INDEX IF EXISTS idx_requests_location_gist;
DROP INDEX IF EXISTS idx_users_location_gist;
-- +goose StatementEnd

View File

@@ -0,0 +1,158 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ФУНКЦИЯ: find_requests_nearby - Геопоиск заявок
-- =========================================
CREATE OR REPLACE FUNCTION find_requests_nearby(
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
radius_meters INTEGER DEFAULT 5000,
req_status request_status DEFAULT 'approved'
)
RETURNS TABLE (
id BIGINT,
title VARCHAR(255),
description TEXT,
address TEXT,
distance_meters DOUBLE PRECISION,
urgency VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT
r.id,
r.title,
r.description,
r.address,
ST_Distance(
r.location,
ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography
) as distance_meters,
r.urgency,
r.created_at
FROM requests r
WHERE
r.status = req_status
AND r.deleted_at IS NULL
AND ST_DWithin(
r.location,
ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography,
radius_meters
)
ORDER BY distance_meters;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION find_requests_nearby IS 'Поиск заявок в радиусе от точки с возвратом расстояния в метрах';
-- =========================================
-- ФУНКЦИЯ: cleanup_expired_tokens - Очистка истёкших токенов
-- =========================================
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Удаляем истёкшие refresh токены
DELETE FROM refresh_tokens
WHERE expires_at < CURRENT_TIMESTAMP
AND revoked = FALSE;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- Удаляем истёкшие сессии
DELETE FROM user_sessions
WHERE expires_at < CURRENT_TIMESTAMP;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION cleanup_expired_tokens IS 'Удаление истёкших токенов и сессий. Рекомендуется запускать по расписанию';
-- =========================================
-- ФУНКЦИЯ: calculate_distance_meters - Расчёт расстояния между точками
-- =========================================
CREATE OR REPLACE FUNCTION calculate_distance_meters(
lat1 DOUBLE PRECISION,
lon1 DOUBLE PRECISION,
lat2 DOUBLE PRECISION,
lon2 DOUBLE PRECISION
)
RETURNS DOUBLE PRECISION AS $$
BEGIN
RETURN ST_Distance(
ST_SetSRID(ST_MakePoint(lon1, lat1), 4326)::geography,
ST_SetSRID(ST_MakePoint(lon2, lat2), 4326)::geography
);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
COMMENT ON FUNCTION calculate_distance_meters IS 'Расчёт расстояния между двумя точками в метрах';
-- =========================================
-- ФУНКЦИЯ: get_user_permissions - Получение прав пользователя
-- =========================================
CREATE OR REPLACE FUNCTION get_user_permissions(p_user_id BIGINT)
RETURNS TABLE (
permission_name VARCHAR(100),
resource VARCHAR(50),
action VARCHAR(50)
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
p.name,
p.resource,
p.action
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE u.id = p_user_id
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION get_user_permissions IS 'Получение всех разрешений пользователя через его роли';
-- =========================================
-- ФУНКЦИЯ: has_permission - Проверка наличия разрешения
-- =========================================
CREATE OR REPLACE FUNCTION has_permission(
p_user_id BIGINT,
p_permission_name VARCHAR(100)
)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS(
SELECT 1
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE u.id = p_user_id
AND p.name = p_permission_name
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
);
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION has_permission IS 'Быстрая проверка наличия конкретного разрешения у пользователя';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP FUNCTION IF EXISTS has_permission;
DROP FUNCTION IF EXISTS get_user_permissions;
DROP FUNCTION IF EXISTS calculate_distance_meters;
DROP FUNCTION IF EXISTS cleanup_expired_tokens;
DROP FUNCTION IF EXISTS find_requests_nearby;
-- +goose StatementEnd

View File

@@ -0,0 +1,198 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ТРИГГЕР: Автообновление updated_at
-- =========================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Применяем триггер к таблицам с updated_at
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_requests_updated_at
BEFORE UPDATE ON requests
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_volunteer_responses_updated_at
BEFORE UPDATE ON volunteer_responses
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_ratings_updated_at
BEFORE UPDATE ON ratings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_complaints_updated_at
BEFORE UPDATE ON complaints
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMENT ON FUNCTION update_updated_at_column IS 'Автоматическое обновление поля updated_at при изменении записи';
-- =========================================
-- ТРИГГЕР: Обновление рейтинга волонтёра
-- =========================================
CREATE OR REPLACE FUNCTION update_volunteer_rating()
RETURNS TRIGGER AS $$
BEGIN
-- Обновляем средний рейтинг и количество выполненных заявок
UPDATE users
SET
volunteer_rating = (
SELECT COALESCE(ROUND(AVG(rating)::numeric, 2), 0)
FROM ratings
WHERE volunteer_id = NEW.volunteer_id
),
completed_requests_count = (
SELECT COUNT(*)
FROM ratings
WHERE volunteer_id = NEW.volunteer_id
)
WHERE id = NEW.volunteer_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Триггеры для INSERT и UPDATE рейтингов
CREATE TRIGGER update_volunteer_rating_on_insert
AFTER INSERT ON ratings
FOR EACH ROW
EXECUTE FUNCTION update_volunteer_rating();
CREATE TRIGGER update_volunteer_rating_on_update
AFTER UPDATE ON ratings
FOR EACH ROW
WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.volunteer_id IS DISTINCT FROM NEW.volunteer_id)
EXECUTE FUNCTION update_volunteer_rating();
COMMENT ON FUNCTION update_volunteer_rating IS 'Автоматический пересчёт рейтинга волонтёра при добавлении/изменении оценки';
-- =========================================
-- ТРИГГЕР: Синхронизация статуса блокировки пользователя
-- =========================================
CREATE OR REPLACE FUNCTION sync_user_block_status()
RETURNS TRIGGER AS $$
BEGIN
-- При INSERT или UPDATE активной блокировки
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') AND NEW.is_active = TRUE THEN
UPDATE users SET is_blocked = TRUE WHERE id = NEW.user_id;
-- При DELETE или деактивации блокировки
ELSIF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.is_active = FALSE) THEN
-- Проверяем, есть ли другие активные блокировки
UPDATE users
SET is_blocked = EXISTS(
SELECT 1
FROM user_blocks
WHERE user_id = COALESCE(NEW.user_id, OLD.user_id)
AND is_active = TRUE
AND id != COALESCE(NEW.id, OLD.id)
)
WHERE id = COALESCE(NEW.user_id, OLD.user_id);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_user_block_status_trigger
AFTER INSERT OR UPDATE OR DELETE ON user_blocks
FOR EACH ROW
EXECUTE FUNCTION sync_user_block_status();
COMMENT ON FUNCTION sync_user_block_status IS 'Синхронизация флага is_blocked в users при изменении блокировок';
-- =========================================
-- ТРИГГЕР: Автоматическое создание записи в истории статусов
-- =========================================
CREATE OR REPLACE FUNCTION log_request_status_change()
RETURNS TRIGGER AS $$
BEGIN
-- При создании заявки
IF TG_OP = 'INSERT' THEN
INSERT INTO request_status_history (request_id, from_status, to_status, changed_by)
VALUES (NEW.id, NULL, NEW.status, NEW.requester_id);
-- При изменении статуса
ELSIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO request_status_history (request_id, from_status, to_status, changed_by)
VALUES (
NEW.id,
OLD.status,
NEW.status,
COALESCE(NEW.moderated_by, NEW.assigned_volunteer_id, NEW.requester_id)
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER log_request_status_change_trigger
AFTER INSERT OR UPDATE ON requests
FOR EACH ROW
EXECUTE FUNCTION log_request_status_change();
COMMENT ON FUNCTION log_request_status_change IS 'Автоматическое логирование всех изменений статусов заявок';
-- =========================================
-- ТРИГГЕР: Проверка истечения временной блокировки
-- =========================================
CREATE OR REPLACE FUNCTION check_block_expiration()
RETURNS TRIGGER AS $$
BEGIN
-- Если блокировка временная и истекла, деактивируем её
IF NEW.blocked_until IS NOT NULL AND NEW.blocked_until < CURRENT_TIMESTAMP THEN
NEW.is_active = FALSE;
NEW.unblocked_at = CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER check_block_expiration_trigger
BEFORE INSERT OR UPDATE ON user_blocks
FOR EACH ROW
WHEN (NEW.blocked_until IS NOT NULL)
EXECUTE FUNCTION check_block_expiration();
COMMENT ON FUNCTION check_block_expiration IS 'Автоматическая деактивация истёкших временных блокировок';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Удаляем триггеры
DROP TRIGGER IF EXISTS check_block_expiration_trigger ON user_blocks;
DROP TRIGGER IF EXISTS log_request_status_change_trigger ON requests;
DROP TRIGGER IF EXISTS sync_user_block_status_trigger ON user_blocks;
DROP TRIGGER IF EXISTS update_volunteer_rating_on_update ON ratings;
DROP TRIGGER IF EXISTS update_volunteer_rating_on_insert ON ratings;
DROP TRIGGER IF EXISTS update_complaints_updated_at ON complaints;
DROP TRIGGER IF EXISTS update_ratings_updated_at ON ratings;
DROP TRIGGER IF EXISTS update_volunteer_responses_updated_at ON volunteer_responses;
DROP TRIGGER IF EXISTS update_requests_updated_at ON requests;
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
-- Удаляем функции
DROP FUNCTION IF EXISTS check_block_expiration;
DROP FUNCTION IF EXISTS log_request_status_change;
DROP FUNCTION IF EXISTS sync_user_block_status;
DROP FUNCTION IF EXISTS update_volunteer_rating;
DROP FUNCTION IF EXISTS update_updated_at_column;
-- +goose StatementEnd

View File

@@ -0,0 +1,246 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ФУНКЦИЯ: match_requests_for_volunteer
-- Алгоритм матчинга заявок для волонтёра
-- =========================================
CREATE OR REPLACE FUNCTION match_requests_for_volunteer(
volunteer_user_id BIGINT,
max_distance_meters INTEGER DEFAULT 10000,
limit_count INTEGER DEFAULT 20
)
RETURNS TABLE (
request_id BIGINT,
title VARCHAR(255),
description TEXT,
address TEXT,
city VARCHAR(100),
distance_meters DOUBLE PRECISION,
urgency VARCHAR(20),
request_type_name VARCHAR(100),
requester_name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE,
match_score DOUBLE PRECISION
) AS $$
DECLARE
v_location GEOGRAPHY;
v_rating NUMERIC;
v_completed_count INTEGER;
BEGIN
-- Получаем данные волонтёра
SELECT location, volunteer_rating, completed_requests_count
INTO v_location, v_rating, v_completed_count
FROM users
WHERE id = volunteer_user_id
AND deleted_at IS NULL
AND is_blocked = FALSE;
-- Проверяем, что волонтёр существует и имеет геолокацию
IF v_location IS NULL THEN
RAISE EXCEPTION 'Volunteer location not set or user not found';
END IF;
RETURN QUERY
SELECT
r.id as request_id,
r.title,
r.description,
r.address,
r.city,
ST_Distance(r.location, v_location) as distance_meters,
r.urgency,
rt.name as request_type_name,
(u.first_name || ' ' || u.last_name) as requester_name,
r.created_at,
-- Расчёт score для сортировки (чем выше, тем лучше подходит)
(
-- Фактор 1: Близость (50% веса)
-- Чем ближе, тем выше score
(1000000.0 / GREATEST(ST_Distance(r.location, v_location), 100)) * 0.5 +
-- Фактор 2: Срочность (30% веса)
(CASE r.urgency
WHEN 'urgent' THEN 100
WHEN 'high' THEN 70
WHEN 'medium' THEN 40
WHEN 'low' THEN 20
ELSE 30
END) * 0.3 +
-- Фактор 3: Давность заявки (20% веса)
-- Старые заявки получают больший приоритет
(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - r.created_at)) / 3600) * 0.2
) as match_score
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
JOIN users u ON u.id = r.requester_id
WHERE
-- Только одобренные заявки
r.status = 'approved'
AND r.deleted_at IS NULL
-- Заявка ещё не взята
AND r.assigned_volunteer_id IS NULL
-- Волонтёр ещё не откликался на эту заявку
AND NOT EXISTS (
SELECT 1
FROM volunteer_responses vr
WHERE vr.request_id = r.id
AND vr.volunteer_id = volunteer_user_id
)
-- В пределах указанного радиуса
AND ST_DWithin(r.location, v_location, max_distance_meters)
-- Заявитель не заблокирован
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
-- Сортировка по score (лучшие подходят первыми)
ORDER BY match_score DESC
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION match_requests_for_volunteer IS 'Алгоритм матчинга: подбирает подходящие заявки для волонтёра на основе геолокации, срочности и давности. Факторы: близость (50%), срочность (30%), давность (20%)';
-- =========================================
-- ФУНКЦИЯ: find_volunteers_for_request
-- Поиск подходящих волонтёров для заявки
-- =========================================
CREATE OR REPLACE FUNCTION find_volunteers_for_request(
p_request_id BIGINT,
max_distance_meters INTEGER DEFAULT 10000,
min_rating NUMERIC DEFAULT 0.0,
limit_count INTEGER DEFAULT 20
)
RETURNS TABLE (
volunteer_id BIGINT,
volunteer_name VARCHAR(255),
volunteer_rating NUMERIC,
completed_requests_count INTEGER,
distance_meters DOUBLE PRECISION,
match_score DOUBLE PRECISION
) AS $$
DECLARE
r_location GEOGRAPHY;
r_urgency VARCHAR(20);
BEGIN
-- Получаем данные заявки
SELECT location, urgency
INTO r_location, r_urgency
FROM requests
WHERE id = p_request_id
AND deleted_at IS NULL;
IF r_location IS NULL THEN
RAISE EXCEPTION 'Request not found or has no location';
END IF;
RETURN QUERY
SELECT
u.id as volunteer_id,
(u.first_name || ' ' || u.last_name) as volunteer_name,
u.volunteer_rating,
u.completed_requests_count,
ST_Distance(u.location, r_location) as distance_meters,
-- Score для сортировки волонтёров
(
-- Близость (40%)
(1000000.0 / GREATEST(ST_Distance(u.location, r_location), 100)) * 0.4 +
-- Рейтинг волонтёра (40%)
(u.volunteer_rating * 20) * 0.4 +
-- Опыт (количество выполненных заявок) (20%)
(LEAST(u.completed_requests_count, 50) * 2) * 0.2
) as match_score
FROM users u
-- Проверяем, что у пользователя есть роль волонтёра
WHERE EXISTS (
SELECT 1
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = u.id
AND r.name = 'volunteer'
)
-- Активный и не заблокированный
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
-- Есть геолокация
AND u.location IS NOT NULL
-- Минимальный рейтинг
AND u.volunteer_rating >= min_rating
-- В пределах радиуса
AND ST_DWithin(u.location, r_location, max_distance_meters)
-- Волонтёр ещё не откликался на эту заявку
AND NOT EXISTS (
SELECT 1
FROM volunteer_responses vr
WHERE vr.request_id = p_request_id
AND vr.volunteer_id = u.id
)
ORDER BY match_score DESC
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION find_volunteers_for_request IS 'Поиск подходящих волонтёров для заявки на основе близости, рейтинга и опыта';
-- =========================================
-- ФУНКЦИЯ: get_volunteer_statistics
-- Статистика волонтёра
-- =========================================
CREATE OR REPLACE FUNCTION get_volunteer_statistics(p_volunteer_id BIGINT)
RETURNS TABLE (
total_responses INTEGER,
accepted_responses INTEGER,
completed_requests INTEGER,
average_rating NUMERIC,
total_ratings INTEGER,
acceptance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(DISTINCT vr.id)::INTEGER as total_responses,
COUNT(DISTINCT CASE WHEN vr.status = 'accepted' THEN vr.id END)::INTEGER as accepted_responses,
COUNT(DISTINCT r.id)::INTEGER as completed_requests,
COALESCE(ROUND(AVG(r.rating), 2), 0) as average_rating,
COUNT(DISTINCT r.id)::INTEGER as total_ratings,
CASE
WHEN COUNT(DISTINCT vr.id) > 0
THEN ROUND((COUNT(DISTINCT CASE WHEN vr.status = 'accepted' THEN vr.id END)::NUMERIC / COUNT(DISTINCT vr.id)::NUMERIC) * 100, 2)
ELSE 0
END as acceptance_rate
FROM users u
LEFT JOIN volunteer_responses vr ON vr.volunteer_id = u.id
LEFT JOIN ratings r ON r.volunteer_id = u.id
WHERE u.id = p_volunteer_id
AND u.deleted_at IS NULL;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION get_volunteer_statistics IS 'Получение детальной статистики волонтёра';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP FUNCTION IF EXISTS get_volunteer_statistics;
DROP FUNCTION IF EXISTS find_volunteers_for_request;
DROP FUNCTION IF EXISTS match_requests_for_volunteer;
-- +goose StatementEnd

View File

@@ -0,0 +1,187 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- НАЧАЛЬНЫЕ ДАННЫЕ: Роли
-- =========================================
INSERT INTO roles (name, description) VALUES
('requester', 'Маломобильный гражданин - может создавать заявки на помощь'),
('volunteer', 'Волонтёр - может откликаться на заявки и оказывать помощь'),
('moderator', 'Модератор - управляет системой, модерирует заявки и жалобы'),
('admin', 'Администратор - полный доступ ко всем функциям системы')
ON CONFLICT (name) DO NOTHING;
-- =========================================
-- НАЧАЛЬНЫЕ ДАННЫЕ: Разрешения
-- =========================================
-- Разрешения для заявок
INSERT INTO permissions (name, resource, action, description) VALUES
('request.create', 'request', 'create', 'Создание заявок на помощь'),
('request.read', 'request', 'read', 'Просмотр заявок'),
('request.read_all', 'request', 'read', 'Просмотр всех заявок (включая чужие)'),
('request.update_own', 'request', 'update', 'Редактирование своих заявок'),
('request.delete_own', 'request', 'delete', 'Удаление своих заявок'),
('request.moderate', 'request', 'moderate', 'Модерация заявок (одобрение/отклонение)'),
('request.delete_any', 'request', 'delete', 'Удаление любых заявок')
ON CONFLICT (name) DO NOTHING;
-- Разрешения для откликов волонтёров
INSERT INTO permissions (name, resource, action, description) VALUES
('volunteer_response.create', 'volunteer_response', 'create', 'Отклик на заявки'),
('volunteer_response.read', 'volunteer_response', 'read', 'Просмотр откликов'),
('volunteer_response.cancel', 'volunteer_response', 'delete', 'Отмена своего отклика')
ON CONFLICT (name) DO NOTHING;
-- Разрешения для рейтингов
INSERT INTO permissions (name, resource, action, description) VALUES
('rating.create', 'rating', 'create', 'Оставление рейтинга волонтёру'),
('rating.read', 'rating', 'read', 'Просмотр рейтингов'),
('rating.read_all', 'rating', 'read', 'Просмотр всех рейтингов')
ON CONFLICT (name) DO NOTHING;
-- Разрешения для жалоб
INSERT INTO permissions (name, resource, action, description) VALUES
('complaint.create', 'complaint', 'create', 'Подача жалобы на пользователя'),
('complaint.read_own', 'complaint', 'read', 'Просмотр своих жалоб'),
('complaint.moderate', 'complaint', 'moderate', 'Обработка жалоб модератором'),
('complaint.read_all', 'complaint', 'read', 'Просмотр всех жалоб')
ON CONFLICT (name) DO NOTHING;
-- Разрешения для пользователей
INSERT INTO permissions (name, resource, action, description) VALUES
('user.read_own', 'user', 'read', 'Просмотр своего профиля'),
('user.update_own', 'user', 'update', 'Редактирование своего профиля'),
('user.read_all', 'user', 'read', 'Просмотр всех пользователей'),
('user.block', 'user', 'block', 'Блокировка пользователей'),
('user.unblock', 'user', 'unblock', 'Разблокировка пользователей')
ON CONFLICT (name) DO NOTHING;
-- Разрешения для модераторских функций
INSERT INTO permissions (name, resource, action, description) VALUES
('moderator.view_logs', 'moderator_action', 'read', 'Просмотр логов модераторов'),
('moderator.view_statistics', 'statistics', 'read', 'Просмотр статистики системы')
ON CONFLICT (name) DO NOTHING;
-- =========================================
-- СВЯЗИ: Роли и Разрешения
-- =========================================
-- Разрешения для роли "requester" (маломобильный гражданин)
INSERT INTO role_permissions (role_id, permission_id)
SELECT
(SELECT id FROM roles WHERE name = 'requester'),
p.id
FROM permissions p
WHERE p.name IN (
'request.create',
'request.read',
'request.update_own',
'request.delete_own',
'rating.create',
'rating.read',
'complaint.create',
'complaint.read_own',
'user.read_own',
'user.update_own'
)
ON CONFLICT DO NOTHING;
-- Разрешения для роли "volunteer" (волонтёр)
INSERT INTO role_permissions (role_id, permission_id)
SELECT
(SELECT id FROM roles WHERE name = 'volunteer'),
p.id
FROM permissions p
WHERE p.name IN (
'request.read',
'volunteer_response.create',
'volunteer_response.read',
'volunteer_response.cancel',
'rating.read',
'complaint.create',
'complaint.read_own',
'user.read_own',
'user.update_own'
)
ON CONFLICT DO NOTHING;
-- Разрешения для роли "moderator" (модератор)
INSERT INTO role_permissions (role_id, permission_id)
SELECT
(SELECT id FROM roles WHERE name = 'moderator'),
p.id
FROM permissions p
WHERE p.name IN (
'request.read_all',
'request.moderate',
'request.delete_any',
'volunteer_response.read',
'rating.read_all',
'complaint.moderate',
'complaint.read_all',
'user.read_all',
'user.block',
'user.unblock',
'moderator.view_logs',
'moderator.view_statistics'
)
ON CONFLICT DO NOTHING;
-- Разрешения для роли "admin" (администратор) - все разрешения
INSERT INTO role_permissions (role_id, permission_id)
SELECT
(SELECT id FROM roles WHERE name = 'admin'),
p.id
FROM permissions p
ON CONFLICT DO NOTHING;
-- =========================================
-- НАЧАЛЬНЫЕ ДАННЫЕ: Типы заявок
-- =========================================
INSERT INTO request_types (name, description, icon, is_active) VALUES
('groceries', 'Покупка продуктов питания', 'shopping-cart', TRUE),
('medicine', 'Покупка медикаментов и средств гигиены', 'medical', TRUE),
('tech_help', 'Помощь с техникой и электроникой', 'laptop', TRUE),
('household', 'Помощь по хозяйству', 'home', TRUE),
('documents', 'Помощь с документами', 'file-text', TRUE),
('other', 'Другая помощь', 'help-circle', TRUE)
ON CONFLICT (name) DO NOTHING;
-- =========================================
-- ВЫВОД ИНФОРМАЦИИ О СОЗДАННЫХ ДАННЫХ
-- =========================================
DO $$
DECLARE
roles_count INTEGER;
permissions_count INTEGER;
role_permissions_count INTEGER;
request_types_count INTEGER;
BEGIN
SELECT COUNT(*) INTO roles_count FROM roles;
SELECT COUNT(*) INTO permissions_count FROM permissions;
SELECT COUNT(*) INTO role_permissions_count FROM role_permissions;
SELECT COUNT(*) INTO request_types_count FROM request_types;
RAISE NOTICE '===========================================';
RAISE NOTICE 'Начальные данные успешно загружены:';
RAISE NOTICE '===========================================';
RAISE NOTICE 'Ролей: %', roles_count;
RAISE NOTICE 'Разрешений: %', permissions_count;
RAISE NOTICE 'Связей роли-разрешения: %', role_permissions_count;
RAISE NOTICE 'Типов заявок: %', request_types_count;
RAISE NOTICE '===========================================';
END $$;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Удаляем начальные данные (в обратном порядке из-за внешних ключей)
DELETE FROM role_permissions;
DELETE FROM request_types;
DELETE FROM permissions;
DELETE FROM roles;
-- +goose StatementEnd

View File

@@ -0,0 +1,82 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ФУНКЦИЯ: Автоматический аудит действий модератора
-- =========================================
CREATE OR REPLACE FUNCTION audit_moderation_action()
RETURNS TRIGGER AS $$
BEGIN
-- Проверяем, изменились ли поля модерации
IF (OLD.moderated_by IS DISTINCT FROM NEW.moderated_by) OR
(OLD.moderated_at IS DISTINCT FROM NEW.moderated_at) THEN
-- Определяем тип действия на основе статуса
IF NEW.status = 'approved' AND OLD.status = 'pending_moderation' THEN
INSERT INTO moderator_actions (
moderator_id,
action_type,
target_request_id,
comment,
metadata
) VALUES (
NEW.moderated_by,
'approve_request',
NEW.id,
NEW.moderation_comment,
jsonb_build_object(
'previous_status', OLD.status::text,
'new_status', NEW.status::text,
'request_title', NEW.title,
'requester_id', NEW.requester_id
)
);
ELSIF NEW.status = 'rejected' AND OLD.status = 'pending_moderation' THEN
INSERT INTO moderator_actions (
moderator_id,
action_type,
target_request_id,
comment,
metadata
) VALUES (
NEW.moderated_by,
'reject_request',
NEW.id,
NEW.moderation_comment,
jsonb_build_object(
'previous_status', OLD.status::text,
'new_status', NEW.status::text,
'request_title', NEW.title,
'requester_id', NEW.requester_id,
'rejection_reason', NEW.moderation_comment
)
);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION audit_moderation_action() IS 'Автоматически создает записи в moderator_actions при модерации заявок';
-- =========================================
-- ТРИГГЕР: Аудит модерации заявок
-- =========================================
CREATE TRIGGER trigger_audit_request_moderation
AFTER UPDATE ON requests
FOR EACH ROW
WHEN (OLD.moderated_by IS DISTINCT FROM NEW.moderated_by OR
OLD.moderated_at IS DISTINCT FROM NEW.moderated_at)
EXECUTE FUNCTION audit_moderation_action();
COMMENT ON TRIGGER trigger_audit_request_moderation ON requests IS
'Автоматически логирует действия модератора в таблицу moderator_actions';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS trigger_audit_request_moderation ON requests;
DROP FUNCTION IF EXISTS audit_moderation_action();
-- +goose StatementEnd

View File

@@ -0,0 +1,306 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- ФУНКЦИЯ: accept_volunteer_response
-- Принятие отклика волонтера с назначением на заявку
-- =========================================
CREATE OR REPLACE FUNCTION accept_volunteer_response(
p_response_id BIGINT,
p_requester_id BIGINT
)
RETURNS TABLE (
success BOOLEAN,
message TEXT,
out_request_id BIGINT,
out_volunteer_id BIGINT
) AS $$
DECLARE
v_request_id BIGINT;
v_volunteer_id BIGINT;
v_request_status request_status;
v_response_status VARCHAR(20);
v_assigned_volunteer_id BIGINT;
v_request_requester_id BIGINT;
BEGIN
-- Получаем информацию об отклике и связанной заявке
SELECT
vr.request_id,
vr.volunteer_id,
vr.status,
r.status,
r.assigned_volunteer_id,
r.requester_id
INTO
v_request_id,
v_volunteer_id,
v_response_status,
v_request_status,
v_assigned_volunteer_id,
v_request_requester_id
FROM volunteer_responses vr
JOIN requests r ON r.id = vr.request_id
WHERE vr.id = p_response_id
AND r.deleted_at IS NULL;
-- Проверка: отклик существует
IF v_request_id IS NULL THEN
RETURN QUERY SELECT FALSE, 'Volunteer response not found'::TEXT, NULL::BIGINT, NULL::BIGINT;
RETURN;
END IF;
-- Проверка: заявка принадлежит заявителю
IF v_request_requester_id != p_requester_id THEN
RETURN QUERY SELECT FALSE, 'You are not the owner of this request'::TEXT, v_request_id, v_volunteer_id;
RETURN;
END IF;
-- Проверка: заявка в статусе 'approved'
IF v_request_status != 'approved' THEN
RETURN QUERY SELECT FALSE, format('Request must be in approved status, current status: %s', v_request_status)::TEXT, v_request_id, v_volunteer_id;
RETURN;
END IF;
-- Проверка: отклик в статусе 'pending'
IF v_response_status != 'pending' THEN
RETURN QUERY SELECT FALSE, format('Response must be in pending status, current status: %s', v_response_status)::TEXT, v_request_id, v_volunteer_id;
RETURN;
END IF;
-- Проверка: заявка еще не взята другим волонтером
IF v_assigned_volunteer_id IS NOT NULL THEN
RETURN QUERY SELECT FALSE, 'Request already assigned to another volunteer'::TEXT, v_request_id, v_volunteer_id;
RETURN;
END IF;
-- Все проверки пройдены, выполняем операцию
-- 1. Принимаем отклик
UPDATE volunteer_responses SET
status = 'accepted',
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_response_id;
-- 2. Назначаем волонтера на заявку и меняем статус
UPDATE requests SET
assigned_volunteer_id = v_volunteer_id,
status = 'in_progress',
updated_at = CURRENT_TIMESTAMP
WHERE id = v_request_id;
-- 3. Отклоняем все остальные отклики на эту заявку
UPDATE volunteer_responses SET
status = 'rejected',
rejected_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE request_id = v_request_id
AND id != p_response_id
AND status = 'pending';
-- Триггер log_request_status_change автоматически создаст запись в request_status_history
RETURN QUERY SELECT TRUE, 'Volunteer response accepted successfully'::TEXT, v_request_id, v_volunteer_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION accept_volunteer_response IS 'Атомарное принятие отклика волонтера: обновление статусов заявки и отклика, отклонение остальных откликов. Предотвращает race conditions.';
-- =========================================
-- ФУНКЦИЯ: complete_request_with_rating
-- Завершение заявки с обязательным выставлением рейтинга
-- =========================================
CREATE OR REPLACE FUNCTION complete_request_with_rating(
p_request_id BIGINT,
p_requester_id BIGINT,
p_rating INTEGER,
p_comment TEXT DEFAULT NULL
)
RETURNS TABLE (
success BOOLEAN,
message TEXT,
out_rating_id BIGINT
) AS $$
DECLARE
v_request_status request_status;
v_volunteer_id BIGINT;
v_requester_id BIGINT;
v_response_id BIGINT;
v_rating_id BIGINT;
BEGIN
-- Получаем информацию о заявке
SELECT
r.status,
r.assigned_volunteer_id,
r.requester_id
INTO
v_request_status,
v_volunteer_id,
v_requester_id
FROM requests r
WHERE r.id = p_request_id
AND r.deleted_at IS NULL;
-- Проверка: заявка существует
IF v_request_status IS NULL THEN
RETURN QUERY SELECT FALSE, 'Request not found'::TEXT, 0::BIGINT;
RETURN;
END IF;
-- Проверка: заявка принадлежит заявителю
IF v_requester_id != p_requester_id THEN
RETURN QUERY SELECT FALSE, 'You are not the owner of this request'::TEXT, 0::BIGINT;
RETURN;
END IF;
-- Проверка: заявка в статусе 'in_progress'
IF v_request_status != 'in_progress' THEN
RETURN QUERY SELECT FALSE, format('Request must be in in_progress status, current status: %s', v_request_status)::TEXT, 0::BIGINT;
RETURN;
END IF;
-- Проверка: есть назначенный волонтер
IF v_volunteer_id IS NULL THEN
RETURN QUERY SELECT FALSE, 'Request has no assigned volunteer'::TEXT, 0::BIGINT;
RETURN;
END IF;
-- Проверка: рейтинг от 1 до 5
IF p_rating < 1 OR p_rating > 5 THEN
RETURN QUERY SELECT FALSE, 'Rating must be between 1 and 5'::TEXT, 0::BIGINT;
RETURN;
END IF;
-- Все проверки пройдены, выполняем операцию
-- 1. Завершаем заявку
UPDATE requests SET
status = 'completed',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_request_id;
-- 2. Получаем ID отклика (для связи с рейтингом)
SELECT id INTO v_response_id
FROM volunteer_responses
WHERE request_id = p_request_id
AND volunteer_id = v_volunteer_id
AND status = 'accepted'
LIMIT 1;
-- 3. Создаем рейтинг
INSERT INTO ratings (
volunteer_response_id,
volunteer_id,
requester_id,
request_id,
rating,
comment
) VALUES (
v_response_id,
v_volunteer_id,
p_requester_id,
p_request_id,
p_rating,
p_comment
) RETURNING id INTO v_rating_id;
-- Триггер update_volunteer_rating автоматически пересчитает рейтинг волонтера
-- Триггер log_request_status_change автоматически создаст запись в request_status_history
RETURN QUERY SELECT TRUE, 'Request completed and rating saved successfully'::TEXT, v_rating_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION complete_request_with_rating IS 'Атомарное завершение заявки с обязательным выставлением рейтинга волонтеру. Триггер автоматически пересчитает средний рейтинг волонтера.';
-- =========================================
-- ФУНКЦИЯ: moderate_request
-- Универсальная функция модерации заявок
-- =========================================
CREATE OR REPLACE FUNCTION moderate_request(
p_request_id BIGINT,
p_moderator_id BIGINT,
p_action TEXT, -- 'approve' или 'reject'
p_comment TEXT DEFAULT NULL
)
RETURNS TABLE (
success BOOLEAN,
message TEXT
) AS $$
DECLARE
v_request_status request_status;
v_has_permission BOOLEAN;
v_new_status request_status;
BEGIN
-- Проверка: модератор имеет право модерировать
SELECT has_permission(p_moderator_id, 'moderate_requests') INTO v_has_permission;
IF NOT v_has_permission THEN
RETURN QUERY SELECT FALSE, 'You do not have permission to moderate requests'::TEXT;
RETURN;
END IF;
-- Получаем текущий статус заявки
SELECT status INTO v_request_status
FROM requests
WHERE id = p_request_id
AND deleted_at IS NULL;
-- Проверка: заявка существует
IF v_request_status IS NULL THEN
RETURN QUERY SELECT FALSE, 'Request not found'::TEXT;
RETURN;
END IF;
-- Проверка: заявка в статусе 'pending_moderation'
IF v_request_status != 'pending_moderation' THEN
RETURN QUERY SELECT FALSE, format('Request must be in pending_moderation status, current status: %s', v_request_status)::TEXT;
RETURN;
END IF;
-- Определяем новый статус
IF p_action = 'approve' THEN
v_new_status := 'approved';
ELSIF p_action = 'reject' THEN
v_new_status := 'rejected';
-- При отклонении комментарий обязателен
IF p_comment IS NULL OR trim(p_comment) = '' THEN
RETURN QUERY SELECT FALSE, 'Comment is required when rejecting a request'::TEXT;
RETURN;
END IF;
ELSE
RETURN QUERY SELECT FALSE, format('Invalid action: %s. Must be approve or reject', p_action)::TEXT;
RETURN;
END IF;
-- Все проверки пройдены, выполняем модерацию
UPDATE requests SET
status = v_new_status,
moderated_by = p_moderator_id,
moderated_at = CURRENT_TIMESTAMP,
moderation_comment = p_comment,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_request_id;
-- Триггер audit_moderation_action автоматически создаст запись в moderator_actions
-- Триггер log_request_status_change автоматически создаст запись в request_status_history
RETURN QUERY SELECT TRUE, format('Request %s successfully', p_action)::TEXT;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION moderate_request IS 'Универсальная функция модерации заявок с проверкой прав, валидацией статуса и автоматическим аудитом через триггеры.';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP FUNCTION IF EXISTS moderate_request;
DROP FUNCTION IF EXISTS complete_request_with_rating;
DROP FUNCTION IF EXISTS accept_volunteer_response;
-- +goose StatementEnd

View File

@@ -0,0 +1,58 @@
-- +goose Up
-- +goose StatementBegin
-- =========================================
-- Разделение full_name на first_name и last_name
-- =========================================
-- Добавляем новые колонки
ALTER TABLE users
ADD COLUMN first_name VARCHAR(100),
ADD COLUMN last_name VARCHAR(100);
-- Копируем данные из full_name в новые поля
-- Разделяем по первому пробелу
UPDATE users
SET
first_name = CASE
WHEN position(' ' in full_name) > 0
THEN split_part(full_name, ' ', 1)
ELSE full_name
END,
last_name = CASE
WHEN position(' ' in full_name) > 0
THEN substring(full_name from position(' ' in full_name) + 1)
ELSE ''
END
WHERE full_name IS NOT NULL;
-- Делаем новые поля обязательными
ALTER TABLE users
ALTER COLUMN first_name SET NOT NULL,
ALTER COLUMN last_name SET NOT NULL;
-- Удаляем старую колонку
ALTER TABLE users DROP COLUMN full_name;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Восстанавливаем full_name
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Объединяем имя и фамилию обратно
UPDATE users
SET full_name = first_name || ' ' || last_name
WHERE first_name IS NOT NULL AND last_name IS NOT NULL;
-- Делаем full_name обязательным
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
-- Удаляем новые колонки
ALTER TABLE users
DROP COLUMN first_name,
DROP COLUMN last_name;
-- +goose StatementEnd