initial commit
This commit is contained in:
22
migrations/00001_enable_extensions.sql
Normal file
22
migrations/00001_enable_extensions.sql
Normal 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
|
||||
56
migrations/00002_create_base_dictionaries.sql
Normal file
56
migrations/00002_create_base_dictionaries.sql
Normal 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
|
||||
59
migrations/00003_create_users_table.sql
Normal file
59
migrations/00003_create_users_table.sql
Normal 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
|
||||
44
migrations/00004_create_rbac_tables.sql
Normal file
44
migrations/00004_create_rbac_tables.sql
Normal 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
|
||||
103
migrations/00005_create_requests_table.sql
Normal file
103
migrations/00005_create_requests_table.sql
Normal 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
|
||||
60
migrations/00006_create_volunteer_responses_table.sql
Normal file
60
migrations/00006_create_volunteer_responses_table.sql
Normal 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
|
||||
43
migrations/00007_create_ratings_table.sql
Normal file
43
migrations/00007_create_ratings_table.sql
Normal 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
|
||||
110
migrations/00008_create_complaints_and_blocks_tables.sql
Normal file
110
migrations/00008_create_complaints_and_blocks_tables.sql
Normal 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
|
||||
60
migrations/00009_create_moderator_actions_table.sql
Normal file
60
migrations/00009_create_moderator_actions_table.sql
Normal 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
|
||||
82
migrations/00010_create_auth_tables.sql
Normal file
82
migrations/00010_create_auth_tables.sql
Normal 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
|
||||
58
migrations/00011_create_indexes_part1.sql
Normal file
58
migrations/00011_create_indexes_part1.sql
Normal 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
|
||||
43
migrations/00012_create_indexes_part2_gist.sql
Normal file
43
migrations/00012_create_indexes_part2_gist.sql
Normal 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
|
||||
158
migrations/00013_create_functions.sql
Normal file
158
migrations/00013_create_functions.sql
Normal 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
|
||||
198
migrations/00014_create_triggers.sql
Normal file
198
migrations/00014_create_triggers.sql
Normal 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
|
||||
246
migrations/00015_create_matching_functions.sql
Normal file
246
migrations/00015_create_matching_functions.sql
Normal 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
|
||||
187
migrations/00016_seed_initial_data.sql
Normal file
187
migrations/00016_seed_initial_data.sql
Normal 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
|
||||
82
migrations/00017_add_moderation_trigger.sql
Normal file
82
migrations/00017_add_moderation_trigger.sql
Normal 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
|
||||
306
migrations/00018_create_business_procedures.sql
Normal file
306
migrations/00018_create_business_procedures.sql
Normal 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
|
||||
58
migrations/00019_split_full_name.sql
Normal file
58
migrations/00019_split_full_name.sql
Normal 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
|
||||
Reference in New Issue
Block a user