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,117 @@
package handlers
import (
"encoding/json"
"net/http"
"git.kirlllll.ru/volontery/backend/internal/api/middleware"
"git.kirlllll.ru/volontery/backend/internal/service"
)
// AuthHandler обрабатывает запросы аутентификации
type AuthHandler struct {
authService *service.AuthService
}
// NewAuthHandler создает новый AuthHandler
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register обрабатывает регистрацию пользователя
// POST /api/v1/auth/register
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req service.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
resp, err := h.authService.Register(r.Context(), req)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusCreated, resp)
}
// Login обрабатывает вход пользователя
// POST /api/v1/auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req service.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
resp, err := h.authService.Login(r.Context(), req)
if err != nil {
respondError(w, http.StatusUnauthorized, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// RefreshToken обрабатывает обновление токенов
// POST /api/v1/auth/refresh
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.RefreshToken == "" {
respondError(w, http.StatusBadRequest, "refresh_token is required")
return
}
resp, err := h.authService.RefreshTokens(r.Context(), req.RefreshToken)
if err != nil {
respondError(w, http.StatusUnauthorized, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// Logout обрабатывает выход пользователя
// POST /api/v1/auth/logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.authService.Logout(r.Context(), userID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to logout")
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "logged out successfully"})
}
// Me возвращает информацию о текущем пользователе
// GET /api/v1/auth/me
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
email, _ := middleware.GetUserEmailFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]interface{}{
"id": userID,
"email": email,
})
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"encoding/json"
"net/http"
)
// ErrorResponse представляет ответ с ошибкой
type ErrorResponse struct {
Error string `json:"error"`
}
// respondJSON отправляет JSON ответ
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if data != nil {
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
}
// respondError отправляет ошибку в формате JSON
func respondError(w http.ResponseWriter, statusCode int, message string) {
respondJSON(w, statusCode, ErrorResponse{Error: message})
}

View File

@@ -0,0 +1,460 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"git.kirlllll.ru/volontery/backend/internal/api/middleware"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/service"
"github.com/go-chi/chi/v5"
)
// RequestHandler обрабатывает запросы заявок
type RequestHandler struct {
requestService *service.RequestService
}
// NewRequestHandler создает новый RequestHandler
func NewRequestHandler(requestService *service.RequestService) *RequestHandler {
return &RequestHandler{
requestService: requestService,
}
}
// CreateRequest создает новую заявку
// POST /api/v1/requests
func (h *RequestHandler) CreateRequest(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input service.CreateRequestInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
input.RequesterID = userID
request, err := h.requestService.CreateRequest(r.Context(), input)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusCreated, request)
}
// GetRequest получает заявку по ID
// GET /api/v1/requests/{id}
func (h *RequestHandler) GetRequest(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
request, err := h.requestService.GetRequest(r.Context(), requestID)
if err != nil {
respondError(w, http.StatusNotFound, "request not found")
return
}
respondJSON(w, http.StatusOK, request)
}
// GetMyRequests получает заявки текущего пользователя
// GET /api/v1/requests/my
func (h *RequestHandler) GetMyRequests(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, offset := parsePagination(r)
requests, err := h.requestService.GetUserRequests(r.Context(), userID, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get requests")
return
}
respondJSON(w, http.StatusOK, requests)
}
// FindNearbyRequests ищет заявки рядом с точкой
// GET /api/v1/requests/nearby
func (h *RequestHandler) FindNearbyRequests(w http.ResponseWriter, r *http.Request) {
latStr := r.URL.Query().Get("lat")
lonStr := r.URL.Query().Get("lon")
radiusStr := r.URL.Query().Get("radius")
if latStr == "" || lonStr == "" {
respondError(w, http.StatusBadRequest, "lat and lon are required")
return
}
lat, err := strconv.ParseFloat(latStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid latitude")
return
}
lon, err := strconv.ParseFloat(lonStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid longitude")
return
}
radius := 5000.0 // default 5km
if radiusStr != "" {
radius, err = strconv.ParseFloat(radiusStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid radius")
return
}
}
limit, offset := parsePagination(r)
// По умолчанию ищем только активные заявки
statuses := []database.RequestStatus{
database.RequestStatusPendingModeration,
database.RequestStatusApproved,
database.RequestStatusInProgress,
}
requests, err := h.requestService.FindNearbyRequests(r.Context(), lat, lon, radius, statuses, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to find requests: %v", err))
return
}
respondJSON(w, http.StatusOK, requests)
}
// FindRequestsInBounds ищет заявки в прямоугольной области
// GET /api/v1/requests/bounds
func (h *RequestHandler) FindRequestsInBounds(w http.ResponseWriter, r *http.Request) {
minLonStr := r.URL.Query().Get("min_lon")
minLatStr := r.URL.Query().Get("min_lat")
maxLonStr := r.URL.Query().Get("max_lon")
maxLatStr := r.URL.Query().Get("max_lat")
if minLonStr == "" || minLatStr == "" || maxLonStr == "" || maxLatStr == "" {
respondError(w, http.StatusBadRequest, "min_lon, min_lat, max_lon, max_lat are required")
return
}
minLon, err := strconv.ParseFloat(minLonStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid min_lon")
return
}
minLat, err := strconv.ParseFloat(minLatStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid min_lat")
return
}
maxLon, err := strconv.ParseFloat(maxLonStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid max_lon")
return
}
maxLat, err := strconv.ParseFloat(maxLatStr, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid max_lat")
return
}
// По умолчанию ищем только активные заявки
statuses := []database.RequestStatus{
database.RequestStatusPendingModeration,
database.RequestStatusApproved,
database.RequestStatusInProgress,
}
requests, err := h.requestService.FindRequestsInBounds(r.Context(), statuses, minLon, minLat, maxLon, maxLat)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to find requests: %v", err))
return
}
respondJSON(w, http.StatusOK, requests)
}
// CreateVolunteerResponse создает отклик волонтера на заявку
// POST /api/v1/requests/{id}/responses
func (h *RequestHandler) CreateVolunteerResponse(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
var input struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
response, err := h.requestService.CreateVolunteerResponse(r.Context(), requestID, userID, input.Message)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusCreated, response)
}
// GetRequestResponses получает отклики на заявку
// GET /api/v1/requests/{id}/responses
func (h *RequestHandler) GetRequestResponses(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
responses, err := h.requestService.GetRequestResponses(r.Context(), requestID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get responses")
return
}
respondJSON(w, http.StatusOK, responses)
}
// ListRequestTypes получает список типов заявок
// GET /api/v1/request-types
func (h *RequestHandler) ListRequestTypes(w http.ResponseWriter, r *http.Request) {
types, err := h.requestService.ListRequestTypes(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get request types")
return
}
respondJSON(w, http.StatusOK, types)
}
// GetPendingModerationRequests получает заявки на модерации
// GET /api/v1/moderation/requests/pending
func (h *RequestHandler) GetPendingModerationRequests(w http.ResponseWriter, r *http.Request) {
limit, offset := parsePagination(r)
requests, err := h.requestService.GetPendingModerationRequests(r.Context(), limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get pending requests")
return
}
respondJSON(w, http.StatusOK, requests)
}
// ApproveRequest одобряет заявку
// POST /api/v1/moderation/requests/{id}/approve
func (h *RequestHandler) ApproveRequest(w http.ResponseWriter, r *http.Request) {
moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
var input struct {
Comment *string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.requestService.ApproveRequest(r.Context(), requestID, moderatorID, input.Comment); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "approved"})
}
// RejectRequest отклоняет заявку
// POST /api/v1/moderation/requests/{id}/reject
func (h *RequestHandler) RejectRequest(w http.ResponseWriter, r *http.Request) {
moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
var input struct {
Comment string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.requestService.RejectRequest(r.Context(), requestID, moderatorID, input.Comment); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
// GetMyModeratedRequests получает заявки, модерированные текущим модератором
// GET /api/v1/moderation/requests/my
func (h *RequestHandler) GetMyModeratedRequests(w http.ResponseWriter, r *http.Request) {
moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, offset := parsePagination(r)
requests, err := h.requestService.GetModeratedRequests(r.Context(), moderatorID, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get moderated requests")
return
}
respondJSON(w, http.StatusOK, requests)
}
// AcceptVolunteerResponseHandler принимает отклик волонтера
// POST /api/v1/requests/{id}/responses/{response_id}/accept
func (h *RequestHandler) AcceptVolunteerResponseHandler(w http.ResponseWriter, r *http.Request) {
requesterID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
responseIDStr := chi.URLParam(r, "response_id")
responseID, err := strconv.ParseInt(responseIDStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid response id")
return
}
result, err := h.requestService.AcceptVolunteerResponse(r.Context(), responseID, requesterID)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if !result.Success {
respondError(w, http.StatusBadRequest, result.Message)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": result.Success,
"message": result.Message,
"request_id": result.RequestID,
"volunteer_id": result.VolunteerID,
})
}
// CompleteRequestWithRatingHandler завершает заявку с рейтингом
// POST /api/v1/requests/{id}/complete
func (h *RequestHandler) CompleteRequestWithRatingHandler(w http.ResponseWriter, r *http.Request) {
requesterID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
var input struct {
Rating int32 `json:"rating"`
Comment *string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.requestService.CompleteRequestWithRating(r.Context(), requestID, requesterID, input.Rating, input.Comment)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if !result.Success {
respondError(w, http.StatusBadRequest, result.Message)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": result.Success,
"message": result.Message,
"rating_id": result.RatingID,
})
}
// parsePagination парсит параметры пагинации из запроса
func parsePagination(r *http.Request) (limit, offset int32) {
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
limit = 20 // default
if limitStr != "" {
if l, err := strconv.ParseInt(limitStr, 10, 32); err == nil && l > 0 && l <= 100 {
limit = int32(l)
}
}
offset = 0
if offsetStr != "" {
if o, err := strconv.ParseInt(offsetStr, 10, 32); err == nil && o >= 0 {
offset = int32(o)
}
}
return limit, offset
}

View File

@@ -0,0 +1,308 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"git.kirlllll.ru/volontery/backend/internal/api/middleware"
"git.kirlllll.ru/volontery/backend/internal/service"
"github.com/go-chi/chi/v5"
)
// UserHandler обрабатывает запросы пользователей
type UserHandler struct {
userService *service.UserService
}
// NewUserHandler создает новый UserHandler
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}
// GetProfile возвращает профиль пользователя
// GET /api/v1/users/{id}
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid user id")
return
}
profile, err := h.userService.GetUserProfile(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
}
respondJSON(w, http.StatusOK, profile)
}
// GetMyProfile возвращает профиль текущего пользователя
// GET /api/v1/users/me
func (h *UserHandler) GetMyProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
profile, err := h.userService.GetUserProfile(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
}
respondJSON(w, http.StatusOK, profile)
}
// UpdateProfile обновляет профиль текущего пользователя
// PATCH /api/v1/users/me
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input service.UpdateProfileInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.userService.UpdateUserProfile(r.Context(), userID, input); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update profile")
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "profile updated successfully"})
}
// UpdateLocation обновляет местоположение пользователя
// POST /api/v1/users/me/location
func (h *UserHandler) UpdateLocation(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.userService.UpdateUserLocation(r.Context(), userID, input.Latitude, input.Longitude); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "location updated successfully"})
}
// GetMyRoles возвращает роли текущего пользователя
// GET /api/v1/users/me/roles
func (h *UserHandler) GetMyRoles(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
roles, err := h.userService.GetUserRoles(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get roles")
return
}
respondJSON(w, http.StatusOK, roles)
}
// GetMyPermissions возвращает разрешения текущего пользователя
// GET /api/v1/users/me/permissions
func (h *UserHandler) GetMyPermissions(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
permissions, err := h.userService.GetUserPermissions(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get permissions")
return
}
respondJSON(w, http.StatusOK, permissions)
}
// VerifyEmail подтверждает email пользователя
// POST /api/v1/users/me/verify-email
func (h *UserHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
// В реальной системе здесь должна быть проверка токена из письма
// Сейчас просто подтверждаем email для текущего пользователя
if err := h.userService.VerifyEmail(r.Context(), userID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to verify email")
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "email verified successfully"})
}
// CheckPermission проверяет наличие разрешения у пользователя
// GET /api/v1/users/me/permissions/{permission_name}/check
func (h *UserHandler) CheckPermission(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
permissionName := chi.URLParam(r, "permission_name")
if permissionName == "" {
respondError(w, http.StatusBadRequest, "permission_name is required")
return
}
hasPermission, err := h.userService.HasPermission(r.Context(), userID, permissionName)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to check permission")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"has_permission": hasPermission,
"permission_name": permissionName,
})
}
// ========== AdminHandler - новый хендлер для административных функций ==========
// AdminHandler обрабатывает административные запросы
type AdminHandler struct {
userService *service.UserService
}
// NewAdminHandler создает новый AdminHandler
func NewAdminHandler(userService *service.UserService) *AdminHandler {
return &AdminHandler{
userService: userService,
}
}
// AssignRole назначает роль пользователю
// POST /api/v1/admin/users/{user_id}/roles/{role_id}
func (h *AdminHandler) AssignRole(w http.ResponseWriter, r *http.Request) {
adminID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
// Проверка, что текущий пользователь является администратором
// В реальной системе это должно быть в middleware
isAdmin, err := h.userService.HasPermission(r.Context(), adminID, "manage_users")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to check permissions")
return
}
if !isAdmin {
respondError(w, http.StatusForbidden, "admin role required")
return
}
userIDStr := chi.URLParam(r, "user_id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid user_id")
return
}
roleIDStr := chi.URLParam(r, "role_id")
roleID, err := strconv.ParseInt(roleIDStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid role_id")
return
}
if err := h.userService.AssignRole(r.Context(), userID, roleID, adminID); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "role assigned successfully",
"user_id": userID,
"role_id": roleID,
})
}
// ========== RequestHandler - дополнительный метод ==========
// ModerateRequestProcedure модерирует заявку через stored procedure
// POST /api/v1/moderation/requests/{id}/moderate
func (h *RequestHandler) ModerateRequestProcedure(w http.ResponseWriter, r *http.Request) {
moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
idStr := chi.URLParam(r, "id")
requestID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid request id")
return
}
var input struct {
Action string `json:"action"`
Comment *string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if input.Action != "approve" && input.Action != "reject" {
respondError(w, http.StatusBadRequest, "action must be 'approve' or 'reject'")
return
}
if input.Action == "reject" && (input.Comment == nil || *input.Comment == "") {
respondError(w, http.StatusBadRequest, "comment is required when rejecting")
return
}
result, err := h.requestService.ModerateRequestProcedure(r.Context(), requestID, moderatorID, input.Action, input.Comment)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if !result.Success {
respondError(w, http.StatusBadRequest, result.Message)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": result.Success,
"message": result.Message,
})
}

25
internal/api/helpers.go Normal file
View File

@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
)
// ErrorResponse represents a JSON error response
type ErrorResponse struct {
Error string `json:"error"`
}
// JSONError sends a JSON error response
func JSONError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}
// JSONResponse sends a JSON response
func JSONResponse(w http.ResponseWriter, data interface{}, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}

View File

@@ -0,0 +1,107 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
)
// ErrorResponse represents a JSON error response
type ErrorResponse struct {
Error string `json:"error"`
}
// JSONError sends a JSON error response
func JSONError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}
// AuthMiddleware создает middleware для проверки JWT токена
func AuthMiddleware(jwtManager *jwt.Manager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Получение токена из заголовка Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
JSONError(w, "authorization header required", http.StatusUnauthorized)
return
}
// Проверка формата "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
JSONError(w, "invalid authorization header format", http.StatusUnauthorized)
return
}
tokenString := parts[1]
// Валидация токена
claims, err := jwtManager.ValidateToken(tokenString)
if err != nil {
JSONError(w, "invalid or expired token", http.StatusUnauthorized)
return
}
// Добавление данных пользователя в контекст
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserIDFromContext извлекает ID пользователя из контекста
func GetUserIDFromContext(ctx context.Context) (int64, bool) {
userID, ok := ctx.Value(UserIDKey).(int64)
return userID, ok
}
// GetUserEmailFromContext извлекает email пользователя из контекста
func GetUserEmailFromContext(ctx context.Context) (string, bool) {
email, ok := ctx.Value(UserEmailKey).(string)
return email, ok
}
// OptionalAuthMiddleware делает аутентификацию опциональной (не возвращает ошибку если токена нет)
func OptionalAuthMiddleware(jwtManager *jwt.Manager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
next.ServeHTTP(w, r)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
next.ServeHTTP(w, r)
return
}
claims, err := jwtManager.ValidateToken(parts[1])
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,94 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"time"
)
// Logger middleware для логирования HTTP запросов
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Обертка для захвата статус кода
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Printf(
"%s %s %d %s",
r.Method,
r.RequestURI,
wrapped.statusCode,
time.Since(start),
)
})
}
// responseWriter обертка для захвата статус кода
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Recovery middleware для восстановления после паник
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v\n%s", err, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// CORS middleware для настройки CORS заголовков
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Проверка разрешенных источников
allowed := false
for _, allowedOrigin := range allowedOrigins {
if allowedOrigin == "*" || allowedOrigin == origin {
allowed = true
break
}
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
}
// Обработка preflight запросов
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// ContentTypeJSON устанавливает Content-Type: application/json
func ContentTypeJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,106 @@
package middleware
import (
"net/http"
"git.kirlllll.ru/volontery/backend/internal/service"
)
// RequirePermission создает middleware для проверки разрешения
func RequirePermission(userService *service.UserService, permission string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
hasPermission, err := userService.HasPermission(r.Context(), userID, permission)
if err != nil {
http.Error(w, "failed to check permissions", http.StatusInternalServerError)
return
}
if !hasPermission {
http.Error(w, "forbidden: insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole создает middleware для проверки роли
func RequireRole(userService *service.UserService, roleName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
roles, err := userService.GetUserRoles(r.Context(), userID)
if err != nil {
http.Error(w, "failed to check roles", http.StatusInternalServerError)
return
}
hasRole := false
for _, role := range roles {
if role.Name == roleName {
hasRole = true
break
}
}
if !hasRole {
http.Error(w, "forbidden: required role not assigned", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAnyRole создает middleware для проверки наличия хотя бы одной из ролей
func RequireAnyRole(userService *service.UserService, roleNames ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
roles, err := userService.GetUserRoles(r.Context(), userID)
if err != nil {
http.Error(w, "failed to check roles", http.StatusInternalServerError)
return
}
hasAnyRole := false
for _, role := range roles {
for _, requiredRole := range roleNames {
if role.Name == requiredRole {
hasAnyRole = true
break
}
}
if hasAnyRole {
break
}
}
if !hasAnyRole {
http.Error(w, "forbidden: none of the required roles assigned", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}

140
internal/api/router.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"net/http"
"git.kirlllll.ru/volontery/backend/internal/api/handlers"
"git.kirlllll.ru/volontery/backend/internal/api/middleware"
"git.kirlllll.ru/volontery/backend/internal/config"
"git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
"git.kirlllll.ru/volontery/backend/internal/service"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
)
// Server представляет HTTP сервер
type Server struct {
router *chi.Mux
authHandler *handlers.AuthHandler
adminHandler *handlers.AdminHandler
userHandler *handlers.UserHandler
requestHandler *handlers.RequestHandler
jwtManager *jwt.Manager
userService *service.UserService
config *config.Config
}
// NewServer создает новый HTTP сервер
func NewServer(
cfg *config.Config,
authService *service.AuthService,
userService *service.UserService,
requestService *service.RequestService,
jwtManager *jwt.Manager,
) *Server {
s := &Server{
router: chi.NewRouter(),
authHandler: handlers.NewAuthHandler(authService),
adminHandler: handlers.NewAdminHandler(userService),
userHandler: handlers.NewUserHandler(userService),
requestHandler: handlers.NewRequestHandler(requestService),
jwtManager: jwtManager,
userService: userService,
config: cfg,
}
s.setupRoutes()
return s
}
// setupRoutes настраивает маршруты
func (s *Server) setupRoutes() {
r := s.router
// Глобальные middleware
r.Use(chiMiddleware.RequestID)
r.Use(chiMiddleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recovery)
r.Use(middleware.CORS(s.config.CORSAllowedOrigins))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// API v1
r.Route("/api/v1", func(r chi.Router) {
// Публичные маршруты (без аутентификации)
r.Group(func(r chi.Router) {
r.Post("/auth/register", s.authHandler.Register)
r.Post("/auth/login", s.authHandler.Login)
r.Post("/auth/refresh", s.authHandler.RefreshToken)
// Типы заявок (публичные)
r.Get("/request-types", s.requestHandler.ListRequestTypes)
})
// Защищенные маршруты (требуют аутентификации)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.jwtManager))
// Auth
r.Get("/auth/me", s.authHandler.Me)
r.Post("/auth/logout", s.authHandler.Logout)
// Users
r.Get("/users/me", s.userHandler.GetMyProfile)
r.Patch("/users/me", s.userHandler.UpdateProfile)
r.Post("/users/me/location", s.userHandler.UpdateLocation)
r.Post("/users/me/verify-email", s.userHandler.VerifyEmail)
r.Get("/users/me/roles", s.userHandler.GetMyRoles)
r.Get("/users/me/permissions", s.userHandler.GetMyPermissions)
r.Get("/users/{id}", s.userHandler.GetProfile)
// Requests
r.Post("/requests", s.requestHandler.CreateRequest)
r.Get("/requests/my", s.requestHandler.GetMyRequests)
r.Get("/requests/nearby", s.requestHandler.FindNearbyRequests)
r.Get("/requests/bounds", s.requestHandler.FindRequestsInBounds)
r.Get("/requests/{id}", s.requestHandler.GetRequest)
r.Post("/requests/{id}/responses", s.requestHandler.CreateVolunteerResponse)
r.Get("/requests/{id}/responses", s.requestHandler.GetRequestResponses)
r.Post("/requests/{id}/responses/{response_id}/accept", s.requestHandler.AcceptVolunteerResponseHandler)
r.Post("/requests/{id}/complete", s.requestHandler.CompleteRequestWithRatingHandler)
})
// Маршруты для модераторов (требуют роль moderator или admin)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.jwtManager))
r.Use(middleware.RequireAnyRole(s.userService, "moderator", "admin"))
// Модерация заявок
r.Get("/moderation/requests/pending", s.requestHandler.GetPendingModerationRequests)
r.Get("/moderation/requests/my", s.requestHandler.GetMyModeratedRequests)
r.Post("/moderation/requests/{id}/approve", s.requestHandler.ApproveRequest)
r.Post("/moderation/requests/{id}/reject", s.requestHandler.RejectRequest)
r.Post("/moderation/requests/{id}/moderate", s.requestHandler.ModerateRequestProcedure)
})
// Маршруты для администраторов (требуют роль admin)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.jwtManager))
r.Use(middleware.RequireRole(s.userService, "admin"))
// Админские маршруты можно добавить позже
r.Get("/admin/users/{id}/roles/{role_id}", s.adminHandler.AssignRole)
})
})
}
// ServeHTTP реализует интерфейс http.Handler
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
// Router возвращает роутер Chi
func (s *Server) Router() *chi.Mux {
return s.router
}

139
internal/config/config.go Normal file
View File

@@ -0,0 +1,139 @@
package config
import (
"context"
"fmt"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
// Config содержит конфигурацию приложения
type Config struct {
// Database
DatabaseURL string
// Server
ServerHost string
ServerPort string
AppEnv string
// JWT
JWTSecret string
JWTAccessTokenTTL time.Duration
JWTRefreshTokenTTL time.Duration
// CORS
CORSAllowedOrigins []string
CORSAllowedMethods []string
CORSAllowedHeaders []string
// Rate Limiting
RateLimitRequestsPerMinute int
RateLimitBurst int
// Matching Algorithm
MatchingDefaultRadiusMeters int
MatchingDefaultLimit int
}
// Load загружает конфигурацию из переменных окружения
func Load() (*Config, error) {
// Загружаем .env файл если он существует
_ = godotenv.Load()
cfg := &Config{
DatabaseURL: getDatabaseURL(),
ServerHost: getEnv("APP_HOST", "0.0.0.0"),
ServerPort: getEnv("APP_PORT", "8080"),
AppEnv: getEnv("APP_ENV", "development"),
JWTSecret: getEnv("JWT_SECRET", "change_me_to_secure_random_string_min_32_chars"),
JWTAccessTokenTTL: parseDuration(getEnv("JWT_ACCESS_TOKEN_EXPIRY", "15m")),
JWTRefreshTokenTTL: parseDuration(getEnv("JWT_REFRESH_TOKEN_EXPIRY", "168h")), // 7 days
RateLimitRequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 60),
RateLimitBurst: getEnvInt("RATE_LIMIT_BURST", 10),
MatchingDefaultRadiusMeters: getEnvInt("MATCHING_DEFAULT_RADIUS_METERS", 10000),
MatchingDefaultLimit: getEnvInt("MATCHING_DEFAULT_LIMIT", 20),
}
return cfg, nil
}
// NewDBPool создает новый пул соединений с БД
func NewDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err)
}
// Настройка пула соединений
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = time.Minute * 30
config.HealthCheckPeriod = time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Проверка соединения
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return pool, nil
}
// getDatabaseURL собирает DATABASE_URL из отдельных переменных или использует готовый
func getDatabaseURL() string {
// Если задан DATABASE_URL, используем его
if url := os.Getenv("DATABASE_URL"); url != "" {
return url
}
// Иначе собираем из отдельных переменных
user := getEnv("DB_USER", "volontery")
password := getEnv("DB_PASSWORD", "volontery")
host := getEnv("DB_HOST", "localhost")
port := getEnv("DB_PORT", "5432")
name := getEnv("DB_NAME", "volontery_db")
sslmode := getEnv("DB_SSLMODE", "disable")
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
user, password, host, port, name, sslmode)
}
// getEnv получает переменную окружения или возвращает значение по умолчанию
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvInt получает целочисленную переменную окружения
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
// parseDuration парсит duration строку
func parseDuration(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
return 15 * time.Minute // default
}
return d
}

View File

@@ -0,0 +1,548 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: auth.sql
package database
import (
"context"
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
)
const CleanupExpiredSessions = `-- name: CleanupExpiredSessions :exec
DELETE FROM user_sessions
WHERE expires_at < CURRENT_TIMESTAMP
OR last_activity_at < CURRENT_TIMESTAMP - INTERVAL '7 days'
`
func (q *Queries) CleanupExpiredSessions(ctx context.Context) error {
_, err := q.db.Exec(ctx, CleanupExpiredSessions)
return err
}
const CleanupExpiredTokens = `-- name: CleanupExpiredTokens :exec
DELETE FROM refresh_tokens
WHERE expires_at < CURRENT_TIMESTAMP
OR (revoked = TRUE AND revoked_at < CURRENT_TIMESTAMP - INTERVAL '30 days')
`
func (q *Queries) CleanupExpiredTokens(ctx context.Context) error {
_, err := q.db.Exec(ctx, CleanupExpiredTokens)
return err
}
const CreateRefreshToken = `-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (
user_id,
token,
expires_at,
user_agent,
ip_address
) VALUES (
$1,
$2,
$3,
$4,
$5
) RETURNING id, user_id, token, expires_at, user_agent, ip_address, revoked, revoked_at, created_at
`
type CreateRefreshTokenParams struct {
UserID int64 `json:"user_id"`
Token string `json:"token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
UserAgent pgtype.Text `json:"user_agent"`
IpAddress *netip.Addr `json:"ip_address"`
}
// ============================================================================
// Refresh Tokens
// ============================================================================
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
row := q.db.QueryRow(ctx, CreateRefreshToken,
arg.UserID,
arg.Token,
arg.ExpiresAt,
arg.UserAgent,
arg.IpAddress,
)
var i RefreshToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Token,
&i.ExpiresAt,
&i.UserAgent,
&i.IpAddress,
&i.Revoked,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (
email,
phone,
password_hash,
first_name,
last_name,
location,
address,
city
) VALUES (
$1,
$2,
$3,
$4,
$5,
ST_SetSRID(ST_MakePoint($6, $7), 4326)::geography,
$8,
$9
) RETURNING
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
`
type CreateUserParams struct {
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
StMakepoint interface{} `json:"st_makepoint"`
StMakepoint_2 interface{} `json:"st_makepoint_2"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
}
type CreateUserRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
// Фаза 1A: Аутентификация (КРИТИЧНО)
// Запросы для регистрации, входа и управления токенами
// ============================================================================
// Пользователи
// ============================================================================
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, CreateUser,
arg.Email,
arg.Phone,
arg.PasswordHash,
arg.FirstName,
arg.LastName,
arg.StMakepoint,
arg.StMakepoint_2,
arg.Address,
arg.City,
)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.PasswordHash,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.Latitude,
&i.Longitude,
&i.Address,
&i.City,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
&i.IsBlocked,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLoginAt,
&i.DeletedAt,
)
return i, err
}
const CreateUserSession = `-- name: CreateUserSession :one
INSERT INTO user_sessions (
user_id,
session_token,
refresh_token_id,
expires_at,
user_agent,
ip_address,
device_info
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7
) RETURNING id, user_id, session_token, refresh_token_id, expires_at, last_activity_at, user_agent, ip_address, device_info, created_at
`
type CreateUserSessionParams struct {
UserID int64 `json:"user_id"`
SessionToken string `json:"session_token"`
RefreshTokenID pgtype.Int8 `json:"refresh_token_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
UserAgent pgtype.Text `json:"user_agent"`
IpAddress *netip.Addr `json:"ip_address"`
DeviceInfo []byte `json:"device_info"`
}
// ============================================================================
// User Sessions
// ============================================================================
func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
row := q.db.QueryRow(ctx, CreateUserSession,
arg.UserID,
arg.SessionToken,
arg.RefreshTokenID,
arg.ExpiresAt,
arg.UserAgent,
arg.IpAddress,
arg.DeviceInfo,
)
var i UserSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.SessionToken,
&i.RefreshTokenID,
&i.ExpiresAt,
&i.LastActivityAt,
&i.UserAgent,
&i.IpAddress,
&i.DeviceInfo,
&i.CreatedAt,
)
return i, err
}
const EmailExists = `-- name: EmailExists :one
SELECT EXISTS(
SELECT 1 FROM users
WHERE email = $1 AND deleted_at IS NULL
)
`
func (q *Queries) EmailExists(ctx context.Context, email string) (bool, error) {
row := q.db.QueryRow(ctx, EmailExists, email)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const GetRefreshToken = `-- name: GetRefreshToken :one
SELECT id, user_id, token, expires_at, user_agent, ip_address, revoked, revoked_at, created_at FROM refresh_tokens
WHERE token = $1
AND revoked = FALSE
AND expires_at > CURRENT_TIMESTAMP
`
func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) {
row := q.db.QueryRow(ctx, GetRefreshToken, token)
var i RefreshToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Token,
&i.ExpiresAt,
&i.UserAgent,
&i.IpAddress,
&i.Revoked,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE email = $1 AND deleted_at IS NULL
`
type GetUserByEmailRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, GetUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.PasswordHash,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.Latitude,
&i.Longitude,
&i.Address,
&i.City,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
&i.IsBlocked,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLoginAt,
&i.DeletedAt,
)
return i, err
}
const GetUserByID = `-- name: GetUserByID :one
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE id = $1 AND deleted_at IS NULL
`
type GetUserByIDRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, GetUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.PasswordHash,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.Latitude,
&i.Longitude,
&i.Address,
&i.City,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
&i.IsBlocked,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLoginAt,
&i.DeletedAt,
)
return i, err
}
const GetUserSession = `-- name: GetUserSession :one
SELECT id, user_id, session_token, refresh_token_id, expires_at, last_activity_at, user_agent, ip_address, device_info, created_at FROM user_sessions
WHERE session_token = $1
AND expires_at > CURRENT_TIMESTAMP
`
func (q *Queries) GetUserSession(ctx context.Context, sessionToken string) (UserSession, error) {
row := q.db.QueryRow(ctx, GetUserSession, sessionToken)
var i UserSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.SessionToken,
&i.RefreshTokenID,
&i.ExpiresAt,
&i.LastActivityAt,
&i.UserAgent,
&i.IpAddress,
&i.DeviceInfo,
&i.CreatedAt,
)
return i, err
}
const InvalidateAllUserSessions = `-- name: InvalidateAllUserSessions :exec
DELETE FROM user_sessions
WHERE user_id = $1
`
func (q *Queries) InvalidateAllUserSessions(ctx context.Context, userID int64) error {
_, err := q.db.Exec(ctx, InvalidateAllUserSessions, userID)
return err
}
const InvalidateUserSession = `-- name: InvalidateUserSession :exec
DELETE FROM user_sessions
WHERE id = $1
`
func (q *Queries) InvalidateUserSession(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, InvalidateUserSession, id)
return err
}
const RevokeAllUserTokens = `-- name: RevokeAllUserTokens :exec
UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND revoked = FALSE
`
func (q *Queries) RevokeAllUserTokens(ctx context.Context, userID int64) error {
_, err := q.db.Exec(ctx, RevokeAllUserTokens, userID)
return err
}
const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) RevokeRefreshToken(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, RevokeRefreshToken, id)
return err
}
const UpdateLastLogin = `-- name: UpdateLastLogin :exec
UPDATE users
SET last_login_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UpdateLastLogin(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, UpdateLastLogin, id)
return err
}
const UpdateSessionActivity = `-- name: UpdateSessionActivity :exec
UPDATE user_sessions
SET last_activity_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UpdateSessionActivity(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, UpdateSessionActivity, id)
return err
}

32
internal/database/db.go Normal file
View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,43 @@
package database
import (
"database/sql/driver"
"fmt"
)
// GeographyPoint представляет PostGIS GEOGRAPHY(POINT) в WGS84
// Для Sprint 1 мы используем ST_X() и ST_Y() в SELECT запросах,
// поэтому этот тип используется только для INSERT операций
type GeographyPoint struct {
Longitude float64
Latitude float64
Valid bool
}
func (g *GeographyPoint) Scan(value interface{}) error {
if value == nil {
g.Valid = false
return nil
}
// В Sprint 1 мы не используем Scan, так как извлекаем координаты через ST_X/ST_Y
// Для production: использовать github.com/twpayne/go-geom для полноценного парсинга
return fmt.Errorf("GeographyPoint.Scan not implemented - use ST_X/ST_Y in queries")
}
// Value реализует driver.Valuer для использования в INSERT/UPDATE запросах
func (g GeographyPoint) Value() (driver.Value, error) {
if !g.Valid {
return nil, nil
}
// Возвращаем WKT формат с SRID для PostGIS
return fmt.Sprintf("SRID=4326;POINT(%f %f)", g.Longitude, g.Latitude), nil
}
// NewGeographyPoint создает новую точку с координатами
func NewGeographyPoint(lon, lat float64) *GeographyPoint {
return &GeographyPoint{
Longitude: lon,
Latitude: lat,
Valid: true,
}
}

View File

@@ -0,0 +1,412 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: geospatial.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CountRequestsNearby = `-- name: CountRequestsNearby :one
SELECT COUNT(*) FROM requests r
WHERE r.deleted_at IS NULL
AND r.status = $3
AND ST_DWithin(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$4
)
`
type CountRequestsNearbyParams struct {
StMakepoint interface{} `json:"st_makepoint"`
StMakepoint_2 interface{} `json:"st_makepoint_2"`
Status NullRequestStatus `json:"status"`
StDwithin interface{} `json:"st_dwithin"`
}
// ============================================================================
// Подсчет заявок поблизости
// ============================================================================
func (q *Queries) CountRequestsNearby(ctx context.Context, arg CountRequestsNearbyParams) (int64, error) {
row := q.db.QueryRow(ctx, CountRequestsNearby,
arg.StMakepoint,
arg.StMakepoint_2,
arg.Status,
arg.StDwithin,
)
var count int64
err := row.Scan(&count)
return count, err
}
const FindNearestRequestsForVolunteer = `-- name: FindNearestRequestsForVolunteer :many
SELECT
r.id,
r.title,
r.description,
r.urgency,
r.status,
r.created_at,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
ST_Distance(
r.location,
(SELECT u.location FROM users u WHERE u.id = $1)
) as distance_meters,
rt.name as request_type_name,
rt.icon as request_type_icon
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
WHERE r.deleted_at IS NULL
AND r.status = 'approved'
AND r.assigned_volunteer_id IS NULL
AND ST_DWithin(
r.location,
(SELECT u.location FROM users u WHERE u.id = $1),
$2
)
ORDER BY
CASE r.urgency
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
distance_meters
LIMIT $3
`
type FindNearestRequestsForVolunteerParams struct {
ID int64 `json:"id"`
StDwithin interface{} `json:"st_dwithin"`
Limit int32 `json:"limit"`
}
type FindNearestRequestsForVolunteerRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Urgency pgtype.Text `json:"urgency"`
Status NullRequestStatus `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
DistanceMeters interface{} `json:"distance_meters"`
RequestTypeName string `json:"request_type_name"`
RequestTypeIcon pgtype.Text `json:"request_type_icon"`
}
// ============================================================================
// Поиск ближайших заявок для волонтера
// ============================================================================
func (q *Queries) FindNearestRequestsForVolunteer(ctx context.Context, arg FindNearestRequestsForVolunteerParams) ([]FindNearestRequestsForVolunteerRow, error) {
rows, err := q.db.Query(ctx, FindNearestRequestsForVolunteer, arg.ID, arg.StDwithin, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FindNearestRequestsForVolunteerRow{}
for rows.Next() {
var i FindNearestRequestsForVolunteerRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Urgency,
&i.Status,
&i.CreatedAt,
&i.Latitude,
&i.Longitude,
&i.DistanceMeters,
&i.RequestTypeName,
&i.RequestTypeIcon,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const FindRequestsInBounds = `-- name: FindRequestsInBounds :many
SELECT
r.id,
r.title,
r.urgency,
r.status,
r.created_at,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
rt.icon as request_type_icon,
rt.name as request_type_name
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
WHERE r.deleted_at IS NULL
AND r.status::text = ANY($1::text[])
AND ST_Within(
r.location::geometry,
ST_MakeEnvelope($2, $3, $4, $5, 4326)
)
ORDER BY r.created_at DESC
LIMIT 200
`
type FindRequestsInBoundsParams struct {
Column1 []string `json:"column_1"`
StMakeenvelope interface{} `json:"st_makeenvelope"`
StMakeenvelope_2 interface{} `json:"st_makeenvelope_2"`
StMakeenvelope_3 interface{} `json:"st_makeenvelope_3"`
StMakeenvelope_4 interface{} `json:"st_makeenvelope_4"`
}
type FindRequestsInBoundsRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Urgency pgtype.Text `json:"urgency"`
Status NullRequestStatus `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
RequestTypeIcon pgtype.Text `json:"request_type_icon"`
RequestTypeName string `json:"request_type_name"`
}
// ============================================================================
// Поиск заявок в прямоугольной области (для карты)
// ============================================================================
func (q *Queries) FindRequestsInBounds(ctx context.Context, arg FindRequestsInBoundsParams) ([]FindRequestsInBoundsRow, error) {
rows, err := q.db.Query(ctx, FindRequestsInBounds,
arg.Column1,
arg.StMakeenvelope,
arg.StMakeenvelope_2,
arg.StMakeenvelope_3,
arg.StMakeenvelope_4,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FindRequestsInBoundsRow{}
for rows.Next() {
var i FindRequestsInBoundsRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Urgency,
&i.Status,
&i.CreatedAt,
&i.Latitude,
&i.Longitude,
&i.RequestTypeIcon,
&i.RequestTypeName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const FindRequestsNearby = `-- name: FindRequestsNearby :many
SELECT
r.id,
r.title,
r.description,
r.address,
r.city,
r.urgency,
r.status,
r.created_at,
r.desired_completion_date,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
ST_Distance(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
) as distance_meters,
rt.name as request_type_name,
rt.icon as request_type_icon,
(u.first_name || ' ' || u.last_name) as requester_name
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.deleted_at IS NULL
AND r.status::text = ANY($3::text[])
AND ST_DWithin(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$4
)
ORDER BY distance_meters
LIMIT $5 OFFSET $6
`
type FindRequestsNearbyParams struct {
StMakepoint interface{} `json:"st_makepoint"`
StMakepoint_2 interface{} `json:"st_makepoint_2"`
Column3 []string `json:"column_3"`
StDwithin interface{} `json:"st_dwithin"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type FindRequestsNearbyRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Address string `json:"address"`
City pgtype.Text `json:"city"`
Urgency pgtype.Text `json:"urgency"`
Status NullRequestStatus `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
DistanceMeters interface{} `json:"distance_meters"`
RequestTypeName string `json:"request_type_name"`
RequestTypeIcon pgtype.Text `json:"request_type_icon"`
RequesterName interface{} `json:"requester_name"`
}
// Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
// PostGIS запросы для поиска заявок по геолокации
// ============================================================================
// Поиск заявок рядом с точкой
// ============================================================================
func (q *Queries) FindRequestsNearby(ctx context.Context, arg FindRequestsNearbyParams) ([]FindRequestsNearbyRow, error) {
rows, err := q.db.Query(ctx, FindRequestsNearby,
arg.StMakepoint,
arg.StMakepoint_2,
arg.Column3,
arg.StDwithin,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FindRequestsNearbyRow{}
for rows.Next() {
var i FindRequestsNearbyRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Address,
&i.City,
&i.Urgency,
&i.Status,
&i.CreatedAt,
&i.DesiredCompletionDate,
&i.Latitude,
&i.Longitude,
&i.DistanceMeters,
&i.RequestTypeName,
&i.RequestTypeIcon,
&i.RequesterName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const FindVolunteersNearRequest = `-- name: FindVolunteersNearRequest :many
SELECT
u.id,
(u.first_name || ' ' || u.last_name) as full_name,
u.avatar_url,
u.volunteer_rating,
u.completed_requests_count,
ST_Y(u.location::geometry) as latitude,
ST_X(u.location::geometry) as longitude,
ST_Distance(
u.location,
(SELECT req.location FROM requests req WHERE req.id = $1)
) as distance_meters
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'volunteer'
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
AND u.location IS NOT NULL
AND ST_DWithin(
u.location,
(SELECT req.location FROM requests req WHERE req.id = $1),
$2
)
ORDER BY distance_meters
LIMIT $3
`
type FindVolunteersNearRequestParams struct {
ID int64 `json:"id"`
StDwithin interface{} `json:"st_dwithin"`
Limit int32 `json:"limit"`
}
type FindVolunteersNearRequestRow struct {
ID int64 `json:"id"`
FullName interface{} `json:"full_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
DistanceMeters interface{} `json:"distance_meters"`
}
// ============================================================================
// Поиск волонтеров рядом с заявкой
// ============================================================================
func (q *Queries) FindVolunteersNearRequest(ctx context.Context, arg FindVolunteersNearRequestParams) ([]FindVolunteersNearRequestRow, error) {
rows, err := q.db.Query(ctx, FindVolunteersNearRequest, arg.ID, arg.StDwithin, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FindVolunteersNearRequestRow{}
for rows.Next() {
var i FindVolunteersNearRequestRow
if err := rows.Scan(
&i.ID,
&i.FullName,
&i.AvatarUrl,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.Latitude,
&i.Longitude,
&i.DistanceMeters,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

530
internal/database/models.go Normal file
View File

@@ -0,0 +1,530 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"database/sql/driver"
"fmt"
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
)
// Статусы жизненного цикла жалобы
type ComplaintStatus string
const (
ComplaintStatusPending ComplaintStatus = "pending"
ComplaintStatusInReview ComplaintStatus = "in_review"
ComplaintStatusResolved ComplaintStatus = "resolved"
ComplaintStatusRejected ComplaintStatus = "rejected"
)
func (e *ComplaintStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ComplaintStatus(s)
case string:
*e = ComplaintStatus(s)
default:
return fmt.Errorf("unsupported scan type for ComplaintStatus: %T", src)
}
return nil
}
type NullComplaintStatus struct {
ComplaintStatus ComplaintStatus `json:"complaint_status"`
Valid bool `json:"valid"` // Valid is true if ComplaintStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullComplaintStatus) Scan(value interface{}) error {
if value == nil {
ns.ComplaintStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ComplaintStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullComplaintStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ComplaintStatus), nil
}
func AllComplaintStatusValues() []ComplaintStatus {
return []ComplaintStatus{
ComplaintStatusPending,
ComplaintStatusInReview,
ComplaintStatusResolved,
ComplaintStatusRejected,
}
}
// Типы жалоб на пользователей
type ComplaintType string
const (
ComplaintTypeInappropriateBehavior ComplaintType = "inappropriate_behavior"
ComplaintTypeNoShow ComplaintType = "no_show"
ComplaintTypeFraud ComplaintType = "fraud"
ComplaintTypeSpam ComplaintType = "spam"
ComplaintTypeOther ComplaintType = "other"
)
func (e *ComplaintType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ComplaintType(s)
case string:
*e = ComplaintType(s)
default:
return fmt.Errorf("unsupported scan type for ComplaintType: %T", src)
}
return nil
}
type NullComplaintType struct {
ComplaintType ComplaintType `json:"complaint_type"`
Valid bool `json:"valid"` // Valid is true if ComplaintType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullComplaintType) Scan(value interface{}) error {
if value == nil {
ns.ComplaintType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ComplaintType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullComplaintType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ComplaintType), nil
}
func AllComplaintTypeValues() []ComplaintType {
return []ComplaintType{
ComplaintTypeInappropriateBehavior,
ComplaintTypeNoShow,
ComplaintTypeFraud,
ComplaintTypeSpam,
ComplaintTypeOther,
}
}
// Типы действий модераторов для аудита
type ModeratorActionType string
const (
ModeratorActionTypeApproveRequest ModeratorActionType = "approve_request"
ModeratorActionTypeRejectRequest ModeratorActionType = "reject_request"
ModeratorActionTypeBlockUser ModeratorActionType = "block_user"
ModeratorActionTypeUnblockUser ModeratorActionType = "unblock_user"
ModeratorActionTypeResolveComplaint ModeratorActionType = "resolve_complaint"
ModeratorActionTypeRejectComplaint ModeratorActionType = "reject_complaint"
ModeratorActionTypeEditRequest ModeratorActionType = "edit_request"
ModeratorActionTypeDeleteRequest ModeratorActionType = "delete_request"
)
func (e *ModeratorActionType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ModeratorActionType(s)
case string:
*e = ModeratorActionType(s)
default:
return fmt.Errorf("unsupported scan type for ModeratorActionType: %T", src)
}
return nil
}
type NullModeratorActionType struct {
ModeratorActionType ModeratorActionType `json:"moderator_action_type"`
Valid bool `json:"valid"` // Valid is true if ModeratorActionType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullModeratorActionType) Scan(value interface{}) error {
if value == nil {
ns.ModeratorActionType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ModeratorActionType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullModeratorActionType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ModeratorActionType), nil
}
func AllModeratorActionTypeValues() []ModeratorActionType {
return []ModeratorActionType{
ModeratorActionTypeApproveRequest,
ModeratorActionTypeRejectRequest,
ModeratorActionTypeBlockUser,
ModeratorActionTypeUnblockUser,
ModeratorActionTypeResolveComplaint,
ModeratorActionTypeRejectComplaint,
ModeratorActionTypeEditRequest,
ModeratorActionTypeDeleteRequest,
}
}
// Статусы жизненного цикла заявки на помощь
type RequestStatus string
const (
RequestStatusPendingModeration RequestStatus = "pending_moderation"
RequestStatusApproved RequestStatus = "approved"
RequestStatusInProgress RequestStatus = "in_progress"
RequestStatusCompleted RequestStatus = "completed"
RequestStatusCancelled RequestStatus = "cancelled"
RequestStatusRejected RequestStatus = "rejected"
)
func (e *RequestStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = RequestStatus(s)
case string:
*e = RequestStatus(s)
default:
return fmt.Errorf("unsupported scan type for RequestStatus: %T", src)
}
return nil
}
type NullRequestStatus struct {
RequestStatus RequestStatus `json:"request_status"`
Valid bool `json:"valid"` // Valid is true if RequestStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullRequestStatus) Scan(value interface{}) error {
if value == nil {
ns.RequestStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.RequestStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullRequestStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.RequestStatus), nil
}
func AllRequestStatusValues() []RequestStatus {
return []RequestStatus{
RequestStatusPendingModeration,
RequestStatusApproved,
RequestStatusInProgress,
RequestStatusCompleted,
RequestStatusCancelled,
RequestStatusRejected,
}
}
// Статусы отклика волонтёра на заявку
type ResponseStatus string
const (
ResponseStatusPending ResponseStatus = "pending"
ResponseStatusAccepted ResponseStatus = "accepted"
ResponseStatusRejected ResponseStatus = "rejected"
ResponseStatusCancelled ResponseStatus = "cancelled"
)
func (e *ResponseStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ResponseStatus(s)
case string:
*e = ResponseStatus(s)
default:
return fmt.Errorf("unsupported scan type for ResponseStatus: %T", src)
}
return nil
}
type NullResponseStatus struct {
ResponseStatus ResponseStatus `json:"response_status"`
Valid bool `json:"valid"` // Valid is true if ResponseStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullResponseStatus) Scan(value interface{}) error {
if value == nil {
ns.ResponseStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ResponseStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullResponseStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ResponseStatus), nil
}
func AllResponseStatusValues() []ResponseStatus {
return []ResponseStatus{
ResponseStatusPending,
ResponseStatusAccepted,
ResponseStatusRejected,
ResponseStatusCancelled,
}
}
// Жалобы пользователей друг на друга
type Complaint struct {
ID int64 `json:"id"`
// Пользователь, подающий жалобу
ComplainantID int64 `json:"complainant_id"`
// Пользователь, на которого жалуются
DefendantID int64 `json:"defendant_id"`
RequestID pgtype.Int8 `json:"request_id"`
Type ComplaintType `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Status NullComplaintStatus `json:"status"`
ModeratorID pgtype.Int8 `json:"moderator_id"`
ModeratorComment pgtype.Text `json:"moderator_comment"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
// Полный аудит всех действий модераторов в системе
type ModeratorAction struct {
ID int64 `json:"id"`
ModeratorID int64 `json:"moderator_id"`
ActionType ModeratorActionType `json:"action_type"`
TargetUserID pgtype.Int8 `json:"target_user_id"`
TargetRequestID pgtype.Int8 `json:"target_request_id"`
TargetComplaintID pgtype.Int8 `json:"target_complaint_id"`
Comment pgtype.Text `json:"comment"`
// Дополнительные данные в JSON (изменённые поля, причины и т.д.)
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Справочник разрешений для RBAC системы
type Permission struct {
ID int64 `json:"id"`
Name string `json:"name"`
// Ресурс: request, user, complaint и т.д.
Resource string `json:"resource"`
// Действие: create, read, update, delete, moderate
Action string `json:"action"`
Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Рейтинги волонтёров за выполненную помощь
type Rating struct {
ID int64 `json:"id"`
// Связь с откликом (один рейтинг на один отклик)
VolunteerResponseID int64 `json:"volunteer_response_id"`
// Денормализация для быстрого доступа
VolunteerID int64 `json:"volunteer_id"`
// Кто оставил рейтинг
RequesterID int64 `json:"requester_id"`
RequestID int64 `json:"request_id"`
// Оценка от 1 до 5 звёзд
Rating int32 `json:"rating"`
Comment pgtype.Text `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
// Refresh токены для JWT аутентификации
type RefreshToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
// Хеш refresh токена
Token string `json:"token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
UserAgent pgtype.Text `json:"user_agent"`
IpAddress *netip.Addr `json:"ip_address"`
// Токен отозван (для принудительного логаута)
Revoked pgtype.Bool `json:"revoked"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Заявки на помощь от маломобильных граждан
type Request struct {
ID int64 `json:"id"`
RequesterID int64 `json:"requester_id"`
RequestTypeID int64 `json:"request_type_id"`
// Волонтёр, который взял заявку в работу
AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
Title string `json:"title"`
Description string `json:"description"`
// Координаты места, где нужна помощь (WGS84, SRID 4326)
Location interface{} `json:"location"`
Address string `json:"address"`
City pgtype.Text `json:"city"`
DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
// Срочность: low, medium, high, urgent
Urgency pgtype.Text `json:"urgency"`
Status NullRequestStatus `json:"status"`
ModerationComment pgtype.Text `json:"moderation_comment"`
ModeratedBy pgtype.Int8 `json:"moderated_by"`
ModeratedAt pgtype.Timestamptz `json:"moderated_at"`
ContactPhone pgtype.Text `json:"contact_phone"`
// Дополнительная информация: код домофона, этаж и т.д.
ContactNotes pgtype.Text `json:"contact_notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
// Soft delete - дата удаления заявки
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
// Полная история изменения статусов заявок для аудита
type RequestStatusHistory struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
// Предыдущий статус (NULL при создании)
FromStatus NullRequestStatus `json:"from_status"`
ToStatus RequestStatus `json:"to_status"`
ChangedBy int64 `json:"changed_by"`
Comment pgtype.Text `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Справочник типов помощи (продукты, медикаменты, техника)
type RequestType struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
// Название иконки для UI
Icon pgtype.Text `json:"icon"`
// Активность типа (для скрытия без удаления)
IsActive pgtype.Bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Справочник ролей для RBAC системы
type Role struct {
ID int64 `json:"id"`
// Уникальное название роли
Name string `json:"name"`
Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Связь ролей и разрешений (Many-to-Many) для гибкой системы RBAC
type RolePermission struct {
ID int64 `json:"id"`
RoleID int64 `json:"role_id"`
PermissionID int64 `json:"permission_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Пользователи системы: маломобильные граждане, волонтёры, модераторы
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
// Координаты домашнего адреса в формате WGS84 (SRID 4326)
Location interface{} `json:"location"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
// Средний рейтинг волонтёра (0-5), обновляется триггером
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
// Количество выполненных заявок, обновляется триггером
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
// Soft delete - дата удаления пользователя
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
// Блокировки пользователей модераторами
type UserBlock struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
BlockedBy int64 `json:"blocked_by"`
ComplaintID pgtype.Int8 `json:"complaint_id"`
Reason string `json:"reason"`
// Дата окончания блокировки (NULL = бессрочная)
BlockedUntil pgtype.Timestamptz `json:"blocked_until"`
// Активна ли блокировка в данный момент
IsActive pgtype.Bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UnblockedAt pgtype.Timestamptz `json:"unblocked_at"`
UnblockedBy pgtype.Int8 `json:"unblocked_by"`
}
// Связь пользователей и ролей (Many-to-Many). Один пользователь может иметь несколько ролей
type UserRole struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
RoleID int64 `json:"role_id"`
AssignedAt pgtype.Timestamptz `json:"assigned_at"`
// Кто назначил роль (для аудита)
AssignedBy pgtype.Int8 `json:"assigned_by"`
}
// Активные сессии пользователей для отслеживания активности
type UserSession struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SessionToken string `json:"session_token"`
RefreshTokenID pgtype.Int8 `json:"refresh_token_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
// Последняя активность пользователя в сессии
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
UserAgent pgtype.Text `json:"user_agent"`
IpAddress *netip.Addr `json:"ip_address"`
// Информация об устройстве: ОС, браузер, версия и т.д.
DeviceInfo []byte `json:"device_info"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// Отклики волонтёров на заявки помощи
type VolunteerResponse struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
VolunteerID int64 `json:"volunteer_id"`
Status NullResponseStatus `json:"status"`
// Сообщение волонтёра при отклике (опционально)
Message pgtype.Text `json:"message"`
// Время создания отклика
RespondedAt pgtype.Timestamptz `json:"responded_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
RejectedAt pgtype.Timestamptz `json:"rejected_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}

View File

@@ -0,0 +1,185 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
AcceptVolunteerResponse(ctx context.Context, id int64) error
ApproveRequest(ctx context.Context, arg ApproveRequestParams) error
AssignRoleToUser(ctx context.Context, arg AssignRoleToUserParams) (UserRole, error)
AssignVolunteerToRequest(ctx context.Context, arg AssignVolunteerToRequestParams) error
BlockUser(ctx context.Context, id int64) error
CalculateVolunteerAverageRating(ctx context.Context, volunteerID int64) (CalculateVolunteerAverageRatingRow, error)
// ============================================================================
// Хранимые процедуры
// ============================================================================
CallAcceptVolunteerResponse(ctx context.Context, arg CallAcceptVolunteerResponseParams) (CallAcceptVolunteerResponseRow, error)
CallCompleteRequestWithRating(ctx context.Context, arg CallCompleteRequestWithRatingParams) (CallCompleteRequestWithRatingRow, error)
CallModerateRequest(ctx context.Context, arg CallModerateRequestParams) (CallModerateRequestRow, error)
CancelRequest(ctx context.Context, id int64) error
CleanupExpiredSessions(ctx context.Context) error
CleanupExpiredTokens(ctx context.Context) error
CompleteRequest(ctx context.Context, id int64) error
CountPendingResponsesByVolunteer(ctx context.Context, volunteerID int64) (int64, error)
// ============================================================================
// Статистика
// ============================================================================
CountRequestsByRequester(ctx context.Context, requesterID int64) (int64, error)
CountRequestsByStatus(ctx context.Context, status NullRequestStatus) (int64, error)
// ============================================================================
// Подсчет заявок поблизости
// ============================================================================
CountRequestsNearby(ctx context.Context, arg CountRequestsNearbyParams) (int64, error)
CountResponsesByRequest(ctx context.Context, requestID int64) (int64, error)
// ============================================================================
// Рейтинги
// ============================================================================
CreateRating(ctx context.Context, arg CreateRatingParams) (Rating, error)
// ============================================================================
// Refresh Tokens
// ============================================================================
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
// Фаза 2A: Управление заявками (ВЫСОКИЙ ПРИОРИТЕТ)
// CRUD операции для заявок на помощь
// ============================================================================
// Создание и получение заявок
// ============================================================================
CreateRequest(ctx context.Context, arg CreateRequestParams) (CreateRequestRow, error)
// ============================================================================
// История изменения статусов заявок
// ============================================================================
CreateStatusHistoryEntry(ctx context.Context, arg CreateStatusHistoryEntryParams) (RequestStatusHistory, error)
// Фаза 1A: Аутентификация (КРИТИЧНО)
// Запросы для регистрации, входа и управления токенами
// ============================================================================
// Пользователи
// ============================================================================
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
// ============================================================================
// User Sessions
// ============================================================================
CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error)
// Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
// Запросы для управления откликами волонтеров и историей изменения статусов заявок
// ============================================================================
// Отклики волонтеров
// ============================================================================
CreateVolunteerResponse(ctx context.Context, arg CreateVolunteerResponseParams) (VolunteerResponse, error)
// ============================================================================
// Удаление заявок
// ============================================================================
DeleteRequest(ctx context.Context, arg DeleteRequestParams) error
EmailExists(ctx context.Context, email string) (bool, error)
// ============================================================================
// Поиск ближайших заявок для волонтера
// ============================================================================
FindNearestRequestsForVolunteer(ctx context.Context, arg FindNearestRequestsForVolunteerParams) ([]FindNearestRequestsForVolunteerRow, error)
// ============================================================================
// Поиск заявок в прямоугольной области (для карты)
// ============================================================================
FindRequestsInBounds(ctx context.Context, arg FindRequestsInBoundsParams) ([]FindRequestsInBoundsRow, error)
// Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
// PostGIS запросы для поиска заявок по геолокации
// ============================================================================
// Поиск заявок рядом с точкой
// ============================================================================
FindRequestsNearby(ctx context.Context, arg FindRequestsNearbyParams) ([]FindRequestsNearbyRow, error)
// ============================================================================
// Поиск волонтеров рядом с заявкой
// ============================================================================
FindVolunteersNearRequest(ctx context.Context, arg FindVolunteersNearRequestParams) ([]FindVolunteersNearRequestRow, error)
GetLatestStatusChange(ctx context.Context, requestID int64) (GetLatestStatusChangeRow, error)
GetModeratedRequests(ctx context.Context, arg GetModeratedRequestsParams) ([]GetModeratedRequestsRow, error)
GetModeratorActionsByModerator(ctx context.Context, arg GetModeratorActionsByModeratorParams) ([]GetModeratorActionsByModeratorRow, error)
// ============================================================================
// Аудит действий модераторов
// ============================================================================
GetModeratorActionsByRequest(ctx context.Context, targetRequestID pgtype.Int8) ([]GetModeratorActionsByRequestRow, error)
// ============================================================================
// Модерация заявок
// ============================================================================
GetPendingModerationRequests(ctx context.Context, arg GetPendingModerationRequestsParams) ([]GetPendingModerationRequestsRow, error)
GetPermissionByName(ctx context.Context, name string) (Permission, error)
GetRatingByResponseID(ctx context.Context, volunteerResponseID int64) (Rating, error)
GetRatingsByVolunteer(ctx context.Context, arg GetRatingsByVolunteerParams) ([]GetRatingsByVolunteerRow, error)
GetRefreshToken(ctx context.Context, token string) (RefreshToken, error)
GetRequestByID(ctx context.Context, id int64) (GetRequestByIDRow, error)
GetRequestStatusHistory(ctx context.Context, requestID int64) ([]GetRequestStatusHistoryRow, error)
GetRequestTypeByID(ctx context.Context, id int64) (RequestType, error)
GetRequestTypeByName(ctx context.Context, name string) (RequestType, error)
GetRequestsByRequester(ctx context.Context, arg GetRequestsByRequesterParams) ([]GetRequestsByRequesterRow, error)
GetRequestsByStatus(ctx context.Context, arg GetRequestsByStatusParams) ([]GetRequestsByStatusRow, error)
GetResponseByID(ctx context.Context, id int64) (GetResponseByIDRow, error)
GetResponsesByRequest(ctx context.Context, requestID int64) ([]GetResponsesByRequestRow, error)
GetResponsesByVolunteer(ctx context.Context, arg GetResponsesByVolunteerParams) ([]GetResponsesByVolunteerRow, error)
GetRoleByID(ctx context.Context, id int64) (Role, error)
// Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
// Запросы для управления ролями и правами доступа
// ============================================================================
// Роли
// ============================================================================
GetRoleByName(ctx context.Context, name string) (Role, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error)
// ============================================================================
// Права доступа
// ============================================================================
GetUserPermissions(ctx context.Context, id int64) ([]GetUserPermissionsRow, error)
// Фаза 1C: Управление профилем (КРИТИЧНО)
// Запросы для получения и обновления профилей пользователей
// ============================================================================
// Профиль пользователя
// ============================================================================
GetUserProfile(ctx context.Context, id int64) (GetUserProfileRow, error)
// ============================================================================
// Пользовательские роли
// ============================================================================
GetUserRoles(ctx context.Context, userID int64) ([]Role, error)
GetUserSession(ctx context.Context, sessionToken string) (UserSession, error)
// ============================================================================
// Поиск пользователей
// ============================================================================
GetUsersByIDs(ctx context.Context, dollar_1 []int64) ([]GetUsersByIDsRow, error)
GetVolunteerStatistics(ctx context.Context, id int64) (GetVolunteerStatisticsRow, error)
InvalidateAllUserSessions(ctx context.Context, userID int64) error
InvalidateUserSession(ctx context.Context, id int64) error
ListAllRoles(ctx context.Context) ([]Role, error)
ListPermissionsByRole(ctx context.Context, roleID int64) ([]Permission, error)
// ============================================================================
// Типы заявок
// ============================================================================
ListRequestTypes(ctx context.Context) ([]RequestType, error)
ModerateRequest(ctx context.Context, arg ModerateRequestParams) error
RejectRequest(ctx context.Context, arg RejectRequestParams) error
RejectVolunteerResponse(ctx context.Context, id int64) error
RemoveRoleFromUser(ctx context.Context, arg RemoveRoleFromUserParams) error
RevokeAllUserTokens(ctx context.Context, userID int64) error
RevokeRefreshToken(ctx context.Context, id int64) error
SearchUsersByName(ctx context.Context, arg SearchUsersByNameParams) ([]SearchUsersByNameRow, error)
SoftDeleteUser(ctx context.Context, id int64) error
UnblockUser(ctx context.Context, id int64) error
UpdateLastLogin(ctx context.Context, id int64) error
UpdateRating(ctx context.Context, arg UpdateRatingParams) error
// ============================================================================
// Обновление заявок
// ============================================================================
UpdateRequestStatus(ctx context.Context, arg UpdateRequestStatusParams) error
UpdateSessionActivity(ctx context.Context, id int64) error
UpdateUserLocation(ctx context.Context, arg UpdateUserLocationParams) error
UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UserHasAnyPermission(ctx context.Context, arg UserHasAnyPermissionParams) (bool, error)
UserHasPermission(ctx context.Context, arg UserHasPermissionParams) (bool, error)
UserHasRole(ctx context.Context, arg UserHasRoleParams) (bool, error)
UserHasRoleByName(ctx context.Context, arg UserHasRoleByNameParams) (bool, error)
VerifyUserEmail(ctx context.Context, id int64) error
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,194 @@
-- Фаза 1A: Аутентификация (КРИТИЧНО)
-- Запросы для регистрации, входа и управления токенами
-- ============================================================================
-- Пользователи
-- ============================================================================
-- name: CreateUser :one
INSERT INTO users (
email,
phone,
password_hash,
first_name,
last_name,
location,
address,
city
) VALUES (
$1,
$2,
$3,
$4,
$5,
ST_SetSRID(ST_MakePoint($6, $7), 4326)::geography,
$8,
$9
) RETURNING
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at;
-- name: GetUserByEmail :one
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE email = $1 AND deleted_at IS NULL;
-- name: GetUserByID :one
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE id = $1 AND deleted_at IS NULL;
-- name: EmailExists :one
SELECT EXISTS(
SELECT 1 FROM users
WHERE email = $1 AND deleted_at IS NULL
);
-- name: UpdateLastLogin :exec
UPDATE users
SET last_login_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- ============================================================================
-- Refresh Tokens
-- ============================================================================
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (
user_id,
token,
expires_at,
user_agent,
ip_address
) VALUES (
$1,
$2,
$3,
$4,
$5
) RETURNING *;
-- name: GetRefreshToken :one
SELECT * FROM refresh_tokens
WHERE token = $1
AND revoked = FALSE
AND expires_at > CURRENT_TIMESTAMP;
-- name: RevokeRefreshToken :exec
UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: RevokeAllUserTokens :exec
UPDATE refresh_tokens
SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND revoked = FALSE;
-- name: CleanupExpiredTokens :exec
DELETE FROM refresh_tokens
WHERE expires_at < CURRENT_TIMESTAMP
OR (revoked = TRUE AND revoked_at < CURRENT_TIMESTAMP - INTERVAL '30 days');
-- ============================================================================
-- User Sessions
-- ============================================================================
-- name: CreateUserSession :one
INSERT INTO user_sessions (
user_id,
session_token,
refresh_token_id,
expires_at,
user_agent,
ip_address,
device_info
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7
) RETURNING *;
-- name: GetUserSession :one
SELECT * FROM user_sessions
WHERE session_token = $1
AND expires_at > CURRENT_TIMESTAMP;
-- name: UpdateSessionActivity :exec
UPDATE user_sessions
SET last_activity_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: InvalidateUserSession :exec
DELETE FROM user_sessions
WHERE id = $1;
-- name: InvalidateAllUserSessions :exec
DELETE FROM user_sessions
WHERE user_id = $1;
-- name: CleanupExpiredSessions :exec
DELETE FROM user_sessions
WHERE expires_at < CURRENT_TIMESTAMP
OR last_activity_at < CURRENT_TIMESTAMP - INTERVAL '7 days';

View File

@@ -0,0 +1,151 @@
-- Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
-- PostGIS запросы для поиска заявок по геолокации
-- ============================================================================
-- Поиск заявок рядом с точкой
-- ============================================================================
-- name: FindRequestsNearby :many
SELECT
r.id,
r.title,
r.description,
r.address,
r.city,
r.urgency,
r.status,
r.created_at,
r.desired_completion_date,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
ST_Distance(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
) as distance_meters,
rt.name as request_type_name,
rt.icon as request_type_icon,
(u.first_name || ' ' || u.last_name) as requester_name
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.deleted_at IS NULL
AND r.status::text = ANY($3::text[])
AND ST_DWithin(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$4
)
ORDER BY distance_meters
LIMIT $5 OFFSET $6;
-- ============================================================================
-- Поиск заявок в прямоугольной области (для карты)
-- ============================================================================
-- name: FindRequestsInBounds :many
SELECT
r.id,
r.title,
r.urgency,
r.status,
r.created_at,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
rt.icon as request_type_icon,
rt.name as request_type_name
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
WHERE r.deleted_at IS NULL
AND r.status::text = ANY($1::text[])
AND ST_Within(
r.location::geometry,
ST_MakeEnvelope($2, $3, $4, $5, 4326)
)
ORDER BY r.created_at DESC
LIMIT 200;
-- ============================================================================
-- Подсчет заявок поблизости
-- ============================================================================
-- name: CountRequestsNearby :one
SELECT COUNT(*) FROM requests r
WHERE r.deleted_at IS NULL
AND r.status = $3
AND ST_DWithin(
r.location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$4
);
-- ============================================================================
-- Поиск волонтеров рядом с заявкой
-- ============================================================================
-- name: FindVolunteersNearRequest :many
SELECT
u.id,
(u.first_name || ' ' || u.last_name) as full_name,
u.avatar_url,
u.volunteer_rating,
u.completed_requests_count,
ST_Y(u.location::geometry) as latitude,
ST_X(u.location::geometry) as longitude,
ST_Distance(
u.location,
(SELECT req.location FROM requests req WHERE req.id = $1)
) as distance_meters
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'volunteer'
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
AND u.location IS NOT NULL
AND ST_DWithin(
u.location,
(SELECT req.location FROM requests req WHERE req.id = $1),
$2
)
ORDER BY distance_meters
LIMIT $3;
-- ============================================================================
-- Поиск ближайших заявок для волонтера
-- ============================================================================
-- name: FindNearestRequestsForVolunteer :many
SELECT
r.id,
r.title,
r.description,
r.urgency,
r.status,
r.created_at,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
ST_Distance(
r.location,
(SELECT u.location FROM users u WHERE u.id = $1)
) as distance_meters,
rt.name as request_type_name,
rt.icon as request_type_icon
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
WHERE r.deleted_at IS NULL
AND r.status = 'approved'
AND r.assigned_volunteer_id IS NULL
AND ST_DWithin(
r.location,
(SELECT u.location FROM users u WHERE u.id = $1),
$2
)
ORDER BY
CASE r.urgency
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
distance_meters
LIMIT $3;

View File

@@ -0,0 +1,102 @@
-- Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
-- Запросы для управления ролями и правами доступа
-- ============================================================================
-- Роли
-- ============================================================================
-- name: GetRoleByName :one
SELECT * FROM roles
WHERE name = $1;
-- name: GetRoleByID :one
SELECT * FROM roles
WHERE id = $1;
-- name: ListAllRoles :many
SELECT * FROM roles
ORDER BY name;
-- ============================================================================
-- Пользовательские роли
-- ============================================================================
-- name: GetUserRoles :many
SELECT r.* FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.name;
-- name: AssignRoleToUser :one
INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, role_id) DO NOTHING
RETURNING *;
-- name: RemoveRoleFromUser :exec
DELETE FROM user_roles
WHERE user_id = $1 AND role_id = $2;
-- name: UserHasRole :one
SELECT EXISTS(
SELECT 1 FROM user_roles
WHERE user_id = $1 AND role_id = $2
);
-- name: UserHasRoleByName :one
SELECT EXISTS(
SELECT 1 FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1 AND r.name = $2
);
-- ============================================================================
-- Права доступа
-- ============================================================================
-- name: GetUserPermissions :many
SELECT DISTINCT p.name, p.resource, p.action, p.description
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 = $1
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
ORDER BY p.resource, p.action;
-- name: GetPermissionByName :one
SELECT * FROM permissions
WHERE name = $1;
-- name: ListPermissionsByRole :many
SELECT p.* FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = $1
ORDER BY p.resource, p.action;
-- name: UserHasPermission :one
SELECT 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 = $1
AND p.name = $2
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
);
-- name: UserHasAnyPermission :one
SELECT 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 = $1
AND p.name = ANY($2::varchar[])
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
);

View File

@@ -0,0 +1,339 @@
-- Фаза 2A: Управление заявками (ВЫСОКИЙ ПРИОРИТЕТ)
-- CRUD операции для заявок на помощь
-- ============================================================================
-- Создание и получение заявок
-- ============================================================================
-- name: CreateRequest :one
INSERT INTO requests (
requester_id,
request_type_id,
title,
description,
location,
address,
city,
desired_completion_date,
urgency,
contact_phone,
contact_notes
) VALUES (
$1,
$2,
$3,
$4,
ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography,
$7,
$8,
$9,
$10,
$11,
$12
) RETURNING
id,
requester_id,
request_type_id,
title,
description,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
desired_completion_date,
urgency,
contact_phone,
contact_notes,
status,
assigned_volunteer_id,
created_at,
updated_at,
deleted_at;
-- name: GetRequestByID :one
SELECT
r.id,
r.requester_id,
r.request_type_id,
r.title,
r.description,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
r.address,
r.city,
r.desired_completion_date,
r.urgency,
r.contact_phone,
r.contact_notes,
r.status,
r.assigned_volunteer_id,
r.created_at,
r.updated_at,
r.deleted_at,
r.completed_at,
rt.name as request_type_name,
rt.icon as request_type_icon,
(u.first_name || ' ' || u.last_name) as requester_name,
u.phone as requester_phone,
u.email as requester_email,
(av.first_name || ' ' || av.last_name) as assigned_volunteer_name,
av.phone as assigned_volunteer_phone
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
JOIN users u ON u.id = r.requester_id
LEFT JOIN users av ON av.id = r.assigned_volunteer_id
WHERE r.id = $1 AND r.deleted_at IS NULL;
-- name: GetRequestsByRequester :many
SELECT
r.id,
r.requester_id,
r.request_type_id,
r.title,
r.description,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
r.address,
r.city,
r.desired_completion_date,
r.urgency,
r.contact_phone,
r.contact_notes,
r.status,
r.assigned_volunteer_id,
r.created_at,
r.updated_at,
r.deleted_at,
rt.name as request_type_name,
rt.icon as request_type_icon
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
WHERE r.requester_id = $1
AND r.deleted_at IS NULL
ORDER BY r.created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetRequestsByStatus :many
SELECT
r.id,
r.requester_id,
r.request_type_id,
r.title,
r.description,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
r.address,
r.city,
r.desired_completion_date,
r.urgency,
r.contact_phone,
r.contact_notes,
r.status,
r.assigned_volunteer_id,
r.created_at,
r.updated_at,
r.deleted_at,
rt.name as request_type_name,
(u.first_name || ' ' || u.last_name) as requester_name
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 = $1
AND r.deleted_at IS NULL
ORDER BY r.created_at DESC
LIMIT $2 OFFSET $3;
-- ============================================================================
-- Обновление заявок
-- ============================================================================
-- name: UpdateRequestStatus :exec
UPDATE requests SET
status = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND deleted_at IS NULL;
-- name: AssignVolunteerToRequest :exec
UPDATE requests SET
assigned_volunteer_id = $2,
status = 'in_progress',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND deleted_at IS NULL;
-- name: CompleteRequest :exec
UPDATE requests SET
status = 'completed',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND deleted_at IS NULL;
-- name: CancelRequest :exec
UPDATE requests SET
status = 'cancelled',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND deleted_at IS NULL;
-- name: ModerateRequest :exec
UPDATE requests SET
status = $2,
moderated_by = $3,
moderated_at = CURRENT_TIMESTAMP,
moderation_comment = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- ============================================================================
-- Удаление заявок
-- ============================================================================
-- name: DeleteRequest :exec
UPDATE requests SET
deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND requester_id = $2
AND deleted_at IS NULL;
-- ============================================================================
-- Типы заявок
-- ============================================================================
-- name: ListRequestTypes :many
SELECT * FROM request_types
WHERE is_active = TRUE
ORDER BY name;
-- name: GetRequestTypeByID :one
SELECT * FROM request_types
WHERE id = $1;
-- name: GetRequestTypeByName :one
SELECT * FROM request_types
WHERE name = $1;
-- ============================================================================
-- Статистика
-- ============================================================================
-- name: CountRequestsByRequester :one
SELECT COUNT(*) FROM requests
WHERE requester_id = $1
AND deleted_at IS NULL;
-- name: CountRequestsByStatus :one
SELECT COUNT(*) FROM requests
WHERE status = $1
AND deleted_at IS NULL;
-- ============================================================================
-- Модерация заявок
-- ============================================================================
-- name: GetPendingModerationRequests :many
SELECT
r.id,
r.requester_id,
r.request_type_id,
r.title,
r.description,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
r.address,
r.city,
r.desired_completion_date,
r.urgency,
r.contact_phone,
r.contact_notes,
r.status,
r.created_at,
r.updated_at,
rt.name as request_type_name,
rt.icon as request_type_icon,
(u.first_name || ' ' || u.last_name) as requester_name,
u.email as requester_email,
u.phone as requester_phone
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 = 'pending_moderation'
AND r.deleted_at IS NULL
ORDER BY r.created_at ASC
LIMIT $1 OFFSET $2;
-- name: ApproveRequest :exec
UPDATE requests SET
status = 'approved',
moderated_by = $2,
moderated_at = CURRENT_TIMESTAMP,
moderation_comment = sqlc.narg('moderation_comment'),
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending_moderation'
AND deleted_at IS NULL;
-- name: RejectRequest :exec
UPDATE requests SET
status = 'rejected',
moderated_by = $2,
moderated_at = CURRENT_TIMESTAMP,
moderation_comment = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'pending_moderation'
AND deleted_at IS NULL;
-- name: GetModeratedRequests :many
SELECT
r.id,
r.requester_id,
r.request_type_id,
r.title,
r.description,
ST_Y(r.location::geometry) as latitude,
ST_X(r.location::geometry) as longitude,
r.address,
r.status,
r.moderated_by,
r.moderated_at,
r.moderation_comment,
r.created_at,
rt.name as request_type_name,
(u.first_name || ' ' || u.last_name) as requester_name,
(m.first_name || ' ' || m.last_name) as moderator_name
FROM requests r
JOIN request_types rt ON rt.id = r.request_type_id
JOIN users u ON u.id = r.requester_id
LEFT JOIN users m ON m.id = r.moderated_by
WHERE r.moderated_by = $1
AND r.deleted_at IS NULL
ORDER BY r.moderated_at DESC
LIMIT $2 OFFSET $3;
-- ============================================================================
-- Аудит действий модераторов
-- ============================================================================
-- name: GetModeratorActionsByRequest :many
SELECT
ma.*,
(u.first_name || ' ' || u.last_name) as moderator_name,
u.email as moderator_email
FROM moderator_actions ma
JOIN users u ON u.id = ma.moderator_id
WHERE ma.target_request_id = $1
ORDER BY ma.created_at DESC;
-- name: GetModeratorActionsByModerator :many
SELECT
ma.*,
r.title as request_title,
r.status as request_status
FROM moderator_actions ma
LEFT JOIN requests r ON r.id = ma.target_request_id
WHERE ma.moderator_id = $1
ORDER BY ma.created_at DESC
LIMIT $2 OFFSET $3;

View File

@@ -0,0 +1,192 @@
-- Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
-- Запросы для управления откликами волонтеров и историей изменения статусов заявок
-- ============================================================================
-- Отклики волонтеров
-- ============================================================================
-- name: CreateVolunteerResponse :one
INSERT INTO volunteer_responses (
request_id,
volunteer_id,
message
) VALUES (
$1,
$2,
$3
)
ON CONFLICT (request_id, volunteer_id) DO NOTHING
RETURNING *;
-- name: GetResponsesByRequest :many
SELECT
vr.*,
(u.first_name || ' ' || u.last_name) as volunteer_name,
u.avatar_url as volunteer_avatar,
u.volunteer_rating,
u.completed_requests_count,
u.email as volunteer_email,
u.phone as volunteer_phone
FROM volunteer_responses vr
JOIN users u ON u.id = vr.volunteer_id
WHERE vr.request_id = $1
ORDER BY vr.created_at DESC;
-- name: GetResponsesByVolunteer :many
SELECT
vr.*,
r.title as request_title,
r.status as request_status,
(u.first_name || ' ' || u.last_name) as requester_name
FROM volunteer_responses vr
JOIN requests r ON r.id = vr.request_id
JOIN users u ON u.id = r.requester_id
WHERE vr.volunteer_id = $1
ORDER BY vr.created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetResponseByID :one
SELECT
vr.*,
(u.first_name || ' ' || u.last_name) as volunteer_name,
r.title as request_title
FROM volunteer_responses vr
JOIN users u ON u.id = vr.volunteer_id
JOIN requests r ON r.id = vr.request_id
WHERE vr.id = $1;
-- name: AcceptVolunteerResponse :exec
UPDATE volunteer_responses SET
status = 'accepted',
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: RejectVolunteerResponse :exec
UPDATE volunteer_responses SET
status = 'rejected',
rejected_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: CountResponsesByRequest :one
SELECT COUNT(*) FROM volunteer_responses
WHERE request_id = $1;
-- name: CountPendingResponsesByVolunteer :one
SELECT COUNT(*) FROM volunteer_responses
WHERE volunteer_id = $1 AND status = 'pending';
-- ============================================================================
-- История изменения статусов заявок
-- ============================================================================
-- name: CreateStatusHistoryEntry :one
INSERT INTO request_status_history (
request_id,
from_status,
to_status,
changed_by,
comment
) VALUES (
$1,
$2,
$3,
$4,
sqlc.narg('comment')
) RETURNING *;
-- name: GetRequestStatusHistory :many
SELECT
rsh.*,
(u.first_name || ' ' || u.last_name) as changed_by_name
FROM request_status_history rsh
JOIN users u ON u.id = rsh.changed_by
WHERE rsh.request_id = $1
ORDER BY rsh.created_at DESC;
-- name: GetLatestStatusChange :one
SELECT
rsh.*,
(u.first_name || ' ' || u.last_name) as changed_by_name
FROM request_status_history rsh
JOIN users u ON u.id = rsh.changed_by
WHERE rsh.request_id = $1
ORDER BY rsh.created_at DESC
LIMIT 1;
-- ============================================================================
-- Рейтинги
-- ============================================================================
-- name: CreateRating :one
INSERT INTO ratings (
volunteer_response_id,
volunteer_id,
requester_id,
request_id,
rating,
comment
) VALUES (
$1,
$2,
$3,
$4,
$5,
sqlc.narg('comment')
) RETURNING *;
-- name: GetRatingByResponseID :one
SELECT * FROM ratings
WHERE volunteer_response_id = $1;
-- name: GetRatingsByVolunteer :many
SELECT
r.*,
req.title as request_title,
(u.first_name || ' ' || u.last_name) as requester_name
FROM ratings r
JOIN requests req ON req.id = r.request_id
JOIN users u ON u.id = r.requester_id
WHERE r.volunteer_id = $1
ORDER BY r.created_at DESC
LIMIT $2 OFFSET $3;
-- name: CalculateVolunteerAverageRating :one
SELECT
COALESCE(AVG(rating), 0) as average_rating,
COUNT(*) as total_ratings
FROM ratings
WHERE volunteer_id = $1;
-- name: UpdateRating :exec
UPDATE ratings SET
rating = $2,
comment = sqlc.narg('comment'),
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- ============================================================================
-- Хранимые процедуры
-- ============================================================================
-- name: CallAcceptVolunteerResponse :one
SELECT
r.success::BOOLEAN,
r.message::TEXT,
r.out_request_id::BIGINT,
r.out_volunteer_id::BIGINT
FROM accept_volunteer_response($1, $2) AS r(success, message, out_request_id, out_volunteer_id);
-- name: CallCompleteRequestWithRating :one
SELECT
r.success::BOOLEAN,
r.message::TEXT,
r.out_rating_id::BIGINT
FROM complete_request_with_rating($1, $2, $3, sqlc.narg('comment')) AS r(success, message, out_rating_id);
-- name: CallModerateRequest :one
SELECT
r.success::BOOLEAN,
r.message::TEXT
FROM moderate_request($1, $2, $3, sqlc.narg('comment')) AS r(success, message);

View File

@@ -0,0 +1,137 @@
-- Фаза 1C: Управление профилем (КРИТИЧНО)
-- Запросы для получения и обновления профилей пользователей
-- ============================================================================
-- Профиль пользователя
-- ============================================================================
-- name: GetUserProfile :one
SELECT
id,
email,
phone,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at
FROM users
WHERE id = $1 AND deleted_at IS NULL;
-- name: UpdateUserProfile :exec
UPDATE users SET
phone = COALESCE(sqlc.narg('phone'), phone),
first_name = COALESCE(sqlc.narg('first_name'), first_name),
last_name = COALESCE(sqlc.narg('last_name'), last_name),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
address = COALESCE(sqlc.narg('address'), address),
city = COALESCE(sqlc.narg('city'), city),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('user_id');
-- name: UpdateUserLocation :exec
UPDATE users SET
location = ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography,
address = COALESCE(sqlc.narg('address'), address),
city = COALESCE(sqlc.narg('city'), city),
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: VerifyUserEmail :exec
UPDATE users SET
email_verified = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: UpdateUserPassword :exec
UPDATE users SET
password_hash = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: BlockUser :exec
UPDATE users SET
is_blocked = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: UnblockUser :exec
UPDATE users SET
is_blocked = FALSE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: SoftDeleteUser :exec
UPDATE users SET
deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- ============================================================================
-- Поиск пользователей
-- ============================================================================
-- name: GetUsersByIDs :many
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE id = ANY($1::bigint[])
AND deleted_at IS NULL;
-- name: SearchUsersByName :many
SELECT
id,
email,
first_name,
last_name,
avatar_url,
volunteer_rating,
completed_requests_count,
is_verified
FROM users
WHERE (first_name ILIKE '%' || $1 || '%' OR last_name ILIKE '%' || $1 || '%' OR (first_name || ' ' || last_name) ILIKE '%' || $1 || '%')
AND deleted_at IS NULL
AND is_blocked = FALSE
ORDER BY volunteer_rating DESC NULLS LAST
LIMIT $2 OFFSET $3;
-- name: GetVolunteerStatistics :one
SELECT
id,
first_name,
last_name,
volunteer_rating,
completed_requests_count,
created_at as member_since
FROM users
WHERE id = $1
AND deleted_at IS NULL;

View File

@@ -0,0 +1,352 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: rbac.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AssignRoleToUser = `-- name: AssignRoleToUser :one
INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, role_id) DO NOTHING
RETURNING id, user_id, role_id, assigned_at, assigned_by
`
type AssignRoleToUserParams struct {
UserID int64 `json:"user_id"`
RoleID int64 `json:"role_id"`
AssignedBy pgtype.Int8 `json:"assigned_by"`
}
func (q *Queries) AssignRoleToUser(ctx context.Context, arg AssignRoleToUserParams) (UserRole, error) {
row := q.db.QueryRow(ctx, AssignRoleToUser, arg.UserID, arg.RoleID, arg.AssignedBy)
var i UserRole
err := row.Scan(
&i.ID,
&i.UserID,
&i.RoleID,
&i.AssignedAt,
&i.AssignedBy,
)
return i, err
}
const GetPermissionByName = `-- name: GetPermissionByName :one
SELECT id, name, resource, action, description, created_at FROM permissions
WHERE name = $1
`
func (q *Queries) GetPermissionByName(ctx context.Context, name string) (Permission, error) {
row := q.db.QueryRow(ctx, GetPermissionByName, name)
var i Permission
err := row.Scan(
&i.ID,
&i.Name,
&i.Resource,
&i.Action,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const GetRoleByID = `-- name: GetRoleByID :one
SELECT id, name, description, created_at FROM roles
WHERE id = $1
`
func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) {
row := q.db.QueryRow(ctx, GetRoleByID, id)
var i Role
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const GetRoleByName = `-- name: GetRoleByName :one
SELECT id, name, description, created_at FROM roles
WHERE name = $1
`
// Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
// Запросы для управления ролями и правами доступа
// ============================================================================
// Роли
// ============================================================================
func (q *Queries) GetRoleByName(ctx context.Context, name string) (Role, error) {
row := q.db.QueryRow(ctx, GetRoleByName, name)
var i Role
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const GetUserPermissions = `-- name: GetUserPermissions :many
SELECT DISTINCT p.name, p.resource, p.action, p.description
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 = $1
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
ORDER BY p.resource, p.action
`
type GetUserPermissionsRow struct {
Name string `json:"name"`
Resource string `json:"resource"`
Action string `json:"action"`
Description pgtype.Text `json:"description"`
}
// ============================================================================
// Права доступа
// ============================================================================
func (q *Queries) GetUserPermissions(ctx context.Context, id int64) ([]GetUserPermissionsRow, error) {
rows, err := q.db.Query(ctx, GetUserPermissions, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUserPermissionsRow{}
for rows.Next() {
var i GetUserPermissionsRow
if err := rows.Scan(
&i.Name,
&i.Resource,
&i.Action,
&i.Description,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserRoles = `-- name: GetUserRoles :many
SELECT r.id, r.name, r.description, r.created_at FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.name
`
// ============================================================================
// Пользовательские роли
// ============================================================================
func (q *Queries) GetUserRoles(ctx context.Context, userID int64) ([]Role, error) {
rows, err := q.db.Query(ctx, GetUserRoles, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Role{}
for rows.Next() {
var i Role
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListAllRoles = `-- name: ListAllRoles :many
SELECT id, name, description, created_at FROM roles
ORDER BY name
`
func (q *Queries) ListAllRoles(ctx context.Context) ([]Role, error) {
rows, err := q.db.Query(ctx, ListAllRoles)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Role{}
for rows.Next() {
var i Role
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListPermissionsByRole = `-- name: ListPermissionsByRole :many
SELECT p.id, p.name, p.resource, p.action, p.description, p.created_at FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = $1
ORDER BY p.resource, p.action
`
func (q *Queries) ListPermissionsByRole(ctx context.Context, roleID int64) ([]Permission, error) {
rows, err := q.db.Query(ctx, ListPermissionsByRole, roleID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Permission{}
for rows.Next() {
var i Permission
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Resource,
&i.Action,
&i.Description,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveRoleFromUser = `-- name: RemoveRoleFromUser :exec
DELETE FROM user_roles
WHERE user_id = $1 AND role_id = $2
`
type RemoveRoleFromUserParams struct {
UserID int64 `json:"user_id"`
RoleID int64 `json:"role_id"`
}
func (q *Queries) RemoveRoleFromUser(ctx context.Context, arg RemoveRoleFromUserParams) error {
_, err := q.db.Exec(ctx, RemoveRoleFromUser, arg.UserID, arg.RoleID)
return err
}
const UserHasAnyPermission = `-- name: UserHasAnyPermission :one
SELECT 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 = $1
AND p.name = ANY($2::varchar[])
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
)
`
type UserHasAnyPermissionParams struct {
ID int64 `json:"id"`
Column2 []string `json:"column_2"`
}
func (q *Queries) UserHasAnyPermission(ctx context.Context, arg UserHasAnyPermissionParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasAnyPermission, arg.ID, arg.Column2)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const UserHasPermission = `-- name: UserHasPermission :one
SELECT 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 = $1
AND p.name = $2
AND u.deleted_at IS NULL
AND u.is_blocked = FALSE
)
`
type UserHasPermissionParams struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
func (q *Queries) UserHasPermission(ctx context.Context, arg UserHasPermissionParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasPermission, arg.ID, arg.Name)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const UserHasRole = `-- name: UserHasRole :one
SELECT EXISTS(
SELECT 1 FROM user_roles
WHERE user_id = $1 AND role_id = $2
)
`
type UserHasRoleParams struct {
UserID int64 `json:"user_id"`
RoleID int64 `json:"role_id"`
}
func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasRole, arg.UserID, arg.RoleID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const UserHasRoleByName = `-- name: UserHasRoleByName :one
SELECT EXISTS(
SELECT 1 FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1 AND r.name = $2
)
`
type UserHasRoleByNameParams struct {
UserID int64 `json:"user_id"`
Name string `json:"name"`
}
func (q *Queries) UserHasRoleByName(ctx context.Context, arg UserHasRoleByNameParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasRoleByName, arg.UserID, arg.Name)
var exists bool
err := row.Scan(&exists)
return exists, err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,713 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: responses.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AcceptVolunteerResponse = `-- name: AcceptVolunteerResponse :exec
UPDATE volunteer_responses SET
status = 'accepted',
accepted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) AcceptVolunteerResponse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, AcceptVolunteerResponse, id)
return err
}
const CalculateVolunteerAverageRating = `-- name: CalculateVolunteerAverageRating :one
SELECT
COALESCE(AVG(rating), 0) as average_rating,
COUNT(*) as total_ratings
FROM ratings
WHERE volunteer_id = $1
`
type CalculateVolunteerAverageRatingRow struct {
AverageRating interface{} `json:"average_rating"`
TotalRatings int64 `json:"total_ratings"`
}
func (q *Queries) CalculateVolunteerAverageRating(ctx context.Context, volunteerID int64) (CalculateVolunteerAverageRatingRow, error) {
row := q.db.QueryRow(ctx, CalculateVolunteerAverageRating, volunteerID)
var i CalculateVolunteerAverageRatingRow
err := row.Scan(&i.AverageRating, &i.TotalRatings)
return i, err
}
const CallAcceptVolunteerResponse = `-- name: CallAcceptVolunteerResponse :one
SELECT
r.success::BOOLEAN,
r.message::TEXT,
r.out_request_id::BIGINT,
r.out_volunteer_id::BIGINT
FROM accept_volunteer_response($1, $2) AS r(success, message, out_request_id, out_volunteer_id)
`
type CallAcceptVolunteerResponseParams struct {
PResponseID int64 `json:"p_response_id"`
PRequesterID int64 `json:"p_requester_id"`
}
type CallAcceptVolunteerResponseRow struct {
Success bool `json:"r_success"`
Message string `json:"r_message"`
RequestID int64 `json:"r_out_request_id"`
VolunteerID int64 `json:"r_out_volunteer_id"`
}
// ============================================================================
// Хранимые процедуры
// ============================================================================
func (q *Queries) CallAcceptVolunteerResponse(ctx context.Context, arg CallAcceptVolunteerResponseParams) (CallAcceptVolunteerResponseRow, error) {
row := q.db.QueryRow(ctx, CallAcceptVolunteerResponse, arg.PResponseID, arg.PRequesterID)
var i CallAcceptVolunteerResponseRow
err := row.Scan(
&i.Success,
&i.Message,
&i.RequestID,
&i.VolunteerID,
)
return i, err
}
const CallCompleteRequestWithRating = `-- name: CallCompleteRequestWithRating :one
SELECT
r.success::BOOLEAN,
r.message::TEXT,
r.out_rating_id::BIGINT
FROM complete_request_with_rating($1, $2, $3, $4) AS r(success, message, out_rating_id)
`
type CallCompleteRequestWithRatingParams struct {
PRequestID int64 `json:"p_request_id"`
PRequesterID int64 `json:"p_requester_id"`
PRating int32 `json:"p_rating"`
Comment pgtype.Text `json:"comment"`
}
type CallCompleteRequestWithRatingRow struct {
Success bool `json:"r_success"`
Message string `json:"r_message"`
RatingID int64 `json:"r_out_rating_id"`
}
func (q *Queries) CallCompleteRequestWithRating(ctx context.Context, arg CallCompleteRequestWithRatingParams) (CallCompleteRequestWithRatingRow, error) {
row := q.db.QueryRow(ctx, CallCompleteRequestWithRating,
arg.PRequestID,
arg.PRequesterID,
arg.PRating,
arg.Comment,
)
var i CallCompleteRequestWithRatingRow
err := row.Scan(&i.Success, &i.Message, &i.RatingID)
return i, err
}
const CallModerateRequest = `-- name: CallModerateRequest :one
SELECT
r.success::BOOLEAN,
r.message::TEXT
FROM moderate_request($1, $2, $3, $4) AS r(success, message)
`
type CallModerateRequestParams struct {
PRequestID int64 `json:"p_request_id"`
PModeratorID int64 `json:"p_moderator_id"`
PAction string `json:"p_action"`
Comment pgtype.Text `json:"comment"`
}
type CallModerateRequestRow struct {
Success bool `json:"r_success"`
Message string `json:"r_message"`
}
func (q *Queries) CallModerateRequest(ctx context.Context, arg CallModerateRequestParams) (CallModerateRequestRow, error) {
row := q.db.QueryRow(ctx, CallModerateRequest,
arg.PRequestID,
arg.PModeratorID,
arg.PAction,
arg.Comment,
)
var i CallModerateRequestRow
err := row.Scan(&i.Success, &i.Message)
return i, err
}
const CountPendingResponsesByVolunteer = `-- name: CountPendingResponsesByVolunteer :one
SELECT COUNT(*) FROM volunteer_responses
WHERE volunteer_id = $1 AND status = 'pending'
`
func (q *Queries) CountPendingResponsesByVolunteer(ctx context.Context, volunteerID int64) (int64, error) {
row := q.db.QueryRow(ctx, CountPendingResponsesByVolunteer, volunteerID)
var count int64
err := row.Scan(&count)
return count, err
}
const CountResponsesByRequest = `-- name: CountResponsesByRequest :one
SELECT COUNT(*) FROM volunteer_responses
WHERE request_id = $1
`
func (q *Queries) CountResponsesByRequest(ctx context.Context, requestID int64) (int64, error) {
row := q.db.QueryRow(ctx, CountResponsesByRequest, requestID)
var count int64
err := row.Scan(&count)
return count, err
}
const CreateRating = `-- name: CreateRating :one
INSERT INTO ratings (
volunteer_response_id,
volunteer_id,
requester_id,
request_id,
rating,
comment
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6
) RETURNING id, volunteer_response_id, volunteer_id, requester_id, request_id, rating, comment, created_at, updated_at
`
type CreateRatingParams struct {
VolunteerResponseID int64 `json:"volunteer_response_id"`
VolunteerID int64 `json:"volunteer_id"`
RequesterID int64 `json:"requester_id"`
RequestID int64 `json:"request_id"`
Rating int32 `json:"rating"`
Comment pgtype.Text `json:"comment"`
}
// ============================================================================
// Рейтинги
// ============================================================================
func (q *Queries) CreateRating(ctx context.Context, arg CreateRatingParams) (Rating, error) {
row := q.db.QueryRow(ctx, CreateRating,
arg.VolunteerResponseID,
arg.VolunteerID,
arg.RequesterID,
arg.RequestID,
arg.Rating,
arg.Comment,
)
var i Rating
err := row.Scan(
&i.ID,
&i.VolunteerResponseID,
&i.VolunteerID,
&i.RequesterID,
&i.RequestID,
&i.Rating,
&i.Comment,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const CreateStatusHistoryEntry = `-- name: CreateStatusHistoryEntry :one
INSERT INTO request_status_history (
request_id,
from_status,
to_status,
changed_by,
comment
) VALUES (
$1,
$2,
$3,
$4,
$5
) RETURNING id, request_id, from_status, to_status, changed_by, comment, created_at
`
type CreateStatusHistoryEntryParams struct {
RequestID int64 `json:"request_id"`
FromStatus NullRequestStatus `json:"from_status"`
ToStatus RequestStatus `json:"to_status"`
ChangedBy int64 `json:"changed_by"`
Comment pgtype.Text `json:"comment"`
}
// ============================================================================
// История изменения статусов заявок
// ============================================================================
func (q *Queries) CreateStatusHistoryEntry(ctx context.Context, arg CreateStatusHistoryEntryParams) (RequestStatusHistory, error) {
row := q.db.QueryRow(ctx, CreateStatusHistoryEntry,
arg.RequestID,
arg.FromStatus,
arg.ToStatus,
arg.ChangedBy,
arg.Comment,
)
var i RequestStatusHistory
err := row.Scan(
&i.ID,
&i.RequestID,
&i.FromStatus,
&i.ToStatus,
&i.ChangedBy,
&i.Comment,
&i.CreatedAt,
)
return i, err
}
const CreateVolunteerResponse = `-- name: CreateVolunteerResponse :one
INSERT INTO volunteer_responses (
request_id,
volunteer_id,
message
) VALUES (
$1,
$2,
$3
)
ON CONFLICT (request_id, volunteer_id) DO NOTHING
RETURNING id, request_id, volunteer_id, status, message, responded_at, accepted_at, rejected_at, created_at, updated_at
`
type CreateVolunteerResponseParams struct {
RequestID int64 `json:"request_id"`
VolunteerID int64 `json:"volunteer_id"`
Message pgtype.Text `json:"message"`
}
// Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
// Запросы для управления откликами волонтеров и историей изменения статусов заявок
// ============================================================================
// Отклики волонтеров
// ============================================================================
func (q *Queries) CreateVolunteerResponse(ctx context.Context, arg CreateVolunteerResponseParams) (VolunteerResponse, error) {
row := q.db.QueryRow(ctx, CreateVolunteerResponse, arg.RequestID, arg.VolunteerID, arg.Message)
var i VolunteerResponse
err := row.Scan(
&i.ID,
&i.RequestID,
&i.VolunteerID,
&i.Status,
&i.Message,
&i.RespondedAt,
&i.AcceptedAt,
&i.RejectedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetLatestStatusChange = `-- name: GetLatestStatusChange :one
SELECT
rsh.id, rsh.request_id, rsh.from_status, rsh.to_status, rsh.changed_by, rsh.comment, rsh.created_at,
(u.first_name || ' ' || u.last_name) as changed_by_name
FROM request_status_history rsh
JOIN users u ON u.id = rsh.changed_by
WHERE rsh.request_id = $1
ORDER BY rsh.created_at DESC
LIMIT 1
`
type GetLatestStatusChangeRow struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
FromStatus NullRequestStatus `json:"from_status"`
ToStatus RequestStatus `json:"to_status"`
ChangedBy int64 `json:"changed_by"`
Comment pgtype.Text `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ChangedByName interface{} `json:"changed_by_name"`
}
func (q *Queries) GetLatestStatusChange(ctx context.Context, requestID int64) (GetLatestStatusChangeRow, error) {
row := q.db.QueryRow(ctx, GetLatestStatusChange, requestID)
var i GetLatestStatusChangeRow
err := row.Scan(
&i.ID,
&i.RequestID,
&i.FromStatus,
&i.ToStatus,
&i.ChangedBy,
&i.Comment,
&i.CreatedAt,
&i.ChangedByName,
)
return i, err
}
const GetRatingByResponseID = `-- name: GetRatingByResponseID :one
SELECT id, volunteer_response_id, volunteer_id, requester_id, request_id, rating, comment, created_at, updated_at FROM ratings
WHERE volunteer_response_id = $1
`
func (q *Queries) GetRatingByResponseID(ctx context.Context, volunteerResponseID int64) (Rating, error) {
row := q.db.QueryRow(ctx, GetRatingByResponseID, volunteerResponseID)
var i Rating
err := row.Scan(
&i.ID,
&i.VolunteerResponseID,
&i.VolunteerID,
&i.RequesterID,
&i.RequestID,
&i.Rating,
&i.Comment,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetRatingsByVolunteer = `-- name: GetRatingsByVolunteer :many
SELECT
r.id, r.volunteer_response_id, r.volunteer_id, r.requester_id, r.request_id, r.rating, r.comment, r.created_at, r.updated_at,
req.title as request_title,
(u.first_name || ' ' || u.last_name) as requester_name
FROM ratings r
JOIN requests req ON req.id = r.request_id
JOIN users u ON u.id = r.requester_id
WHERE r.volunteer_id = $1
ORDER BY r.created_at DESC
LIMIT $2 OFFSET $3
`
type GetRatingsByVolunteerParams struct {
VolunteerID int64 `json:"volunteer_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type GetRatingsByVolunteerRow struct {
ID int64 `json:"id"`
VolunteerResponseID int64 `json:"volunteer_response_id"`
VolunteerID int64 `json:"volunteer_id"`
RequesterID int64 `json:"requester_id"`
RequestID int64 `json:"request_id"`
Rating int32 `json:"rating"`
Comment pgtype.Text `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
RequestTitle string `json:"request_title"`
RequesterName interface{} `json:"requester_name"`
}
func (q *Queries) GetRatingsByVolunteer(ctx context.Context, arg GetRatingsByVolunteerParams) ([]GetRatingsByVolunteerRow, error) {
rows, err := q.db.Query(ctx, GetRatingsByVolunteer, arg.VolunteerID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRatingsByVolunteerRow{}
for rows.Next() {
var i GetRatingsByVolunteerRow
if err := rows.Scan(
&i.ID,
&i.VolunteerResponseID,
&i.VolunteerID,
&i.RequesterID,
&i.RequestID,
&i.Rating,
&i.Comment,
&i.CreatedAt,
&i.UpdatedAt,
&i.RequestTitle,
&i.RequesterName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetRequestStatusHistory = `-- name: GetRequestStatusHistory :many
SELECT
rsh.id, rsh.request_id, rsh.from_status, rsh.to_status, rsh.changed_by, rsh.comment, rsh.created_at,
(u.first_name || ' ' || u.last_name) as changed_by_name
FROM request_status_history rsh
JOIN users u ON u.id = rsh.changed_by
WHERE rsh.request_id = $1
ORDER BY rsh.created_at DESC
`
type GetRequestStatusHistoryRow struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
FromStatus NullRequestStatus `json:"from_status"`
ToStatus RequestStatus `json:"to_status"`
ChangedBy int64 `json:"changed_by"`
Comment pgtype.Text `json:"comment"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ChangedByName interface{} `json:"changed_by_name"`
}
func (q *Queries) GetRequestStatusHistory(ctx context.Context, requestID int64) ([]GetRequestStatusHistoryRow, error) {
rows, err := q.db.Query(ctx, GetRequestStatusHistory, requestID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRequestStatusHistoryRow{}
for rows.Next() {
var i GetRequestStatusHistoryRow
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.FromStatus,
&i.ToStatus,
&i.ChangedBy,
&i.Comment,
&i.CreatedAt,
&i.ChangedByName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetResponseByID = `-- name: GetResponseByID :one
SELECT
vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
(u.first_name || ' ' || u.last_name) as volunteer_name,
r.title as request_title
FROM volunteer_responses vr
JOIN users u ON u.id = vr.volunteer_id
JOIN requests r ON r.id = vr.request_id
WHERE vr.id = $1
`
type GetResponseByIDRow struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
VolunteerID int64 `json:"volunteer_id"`
Status NullResponseStatus `json:"status"`
Message pgtype.Text `json:"message"`
RespondedAt pgtype.Timestamptz `json:"responded_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
RejectedAt pgtype.Timestamptz `json:"rejected_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
VolunteerName interface{} `json:"volunteer_name"`
RequestTitle string `json:"request_title"`
}
func (q *Queries) GetResponseByID(ctx context.Context, id int64) (GetResponseByIDRow, error) {
row := q.db.QueryRow(ctx, GetResponseByID, id)
var i GetResponseByIDRow
err := row.Scan(
&i.ID,
&i.RequestID,
&i.VolunteerID,
&i.Status,
&i.Message,
&i.RespondedAt,
&i.AcceptedAt,
&i.RejectedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.VolunteerName,
&i.RequestTitle,
)
return i, err
}
const GetResponsesByRequest = `-- name: GetResponsesByRequest :many
SELECT
vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
(u.first_name || ' ' || u.last_name) as volunteer_name,
u.avatar_url as volunteer_avatar,
u.volunteer_rating,
u.completed_requests_count,
u.email as volunteer_email,
u.phone as volunteer_phone
FROM volunteer_responses vr
JOIN users u ON u.id = vr.volunteer_id
WHERE vr.request_id = $1
ORDER BY vr.created_at DESC
`
type GetResponsesByRequestRow struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
VolunteerID int64 `json:"volunteer_id"`
Status NullResponseStatus `json:"status"`
Message pgtype.Text `json:"message"`
RespondedAt pgtype.Timestamptz `json:"responded_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
RejectedAt pgtype.Timestamptz `json:"rejected_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
VolunteerName interface{} `json:"volunteer_name"`
VolunteerAvatar pgtype.Text `json:"volunteer_avatar"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
VolunteerEmail string `json:"volunteer_email"`
VolunteerPhone pgtype.Text `json:"volunteer_phone"`
}
func (q *Queries) GetResponsesByRequest(ctx context.Context, requestID int64) ([]GetResponsesByRequestRow, error) {
rows, err := q.db.Query(ctx, GetResponsesByRequest, requestID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetResponsesByRequestRow{}
for rows.Next() {
var i GetResponsesByRequestRow
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.VolunteerID,
&i.Status,
&i.Message,
&i.RespondedAt,
&i.AcceptedAt,
&i.RejectedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.VolunteerName,
&i.VolunteerAvatar,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.VolunteerEmail,
&i.VolunteerPhone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetResponsesByVolunteer = `-- name: GetResponsesByVolunteer :many
SELECT
vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
r.title as request_title,
r.status as request_status,
(u.first_name || ' ' || u.last_name) as requester_name
FROM volunteer_responses vr
JOIN requests r ON r.id = vr.request_id
JOIN users u ON u.id = r.requester_id
WHERE vr.volunteer_id = $1
ORDER BY vr.created_at DESC
LIMIT $2 OFFSET $3
`
type GetResponsesByVolunteerParams struct {
VolunteerID int64 `json:"volunteer_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type GetResponsesByVolunteerRow struct {
ID int64 `json:"id"`
RequestID int64 `json:"request_id"`
VolunteerID int64 `json:"volunteer_id"`
Status NullResponseStatus `json:"status"`
Message pgtype.Text `json:"message"`
RespondedAt pgtype.Timestamptz `json:"responded_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
RejectedAt pgtype.Timestamptz `json:"rejected_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
RequestTitle string `json:"request_title"`
RequestStatus NullRequestStatus `json:"request_status"`
RequesterName interface{} `json:"requester_name"`
}
func (q *Queries) GetResponsesByVolunteer(ctx context.Context, arg GetResponsesByVolunteerParams) ([]GetResponsesByVolunteerRow, error) {
rows, err := q.db.Query(ctx, GetResponsesByVolunteer, arg.VolunteerID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetResponsesByVolunteerRow{}
for rows.Next() {
var i GetResponsesByVolunteerRow
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.VolunteerID,
&i.Status,
&i.Message,
&i.RespondedAt,
&i.AcceptedAt,
&i.RejectedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.RequestTitle,
&i.RequestStatus,
&i.RequesterName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RejectVolunteerResponse = `-- name: RejectVolunteerResponse :exec
UPDATE volunteer_responses SET
status = 'rejected',
rejected_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) RejectVolunteerResponse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, RejectVolunteerResponse, id)
return err
}
const UpdateRating = `-- name: UpdateRating :exec
UPDATE ratings SET
rating = $2,
comment = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
type UpdateRatingParams struct {
ID int64 `json:"id"`
Rating int32 `json:"rating"`
Comment pgtype.Text `json:"comment"`
}
func (q *Queries) UpdateRating(ctx context.Context, arg UpdateRatingParams) error {
_, err := q.db.Exec(ctx, UpdateRating, arg.ID, arg.Rating, arg.Comment)
return err
}

View File

@@ -0,0 +1,413 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const BlockUser = `-- name: BlockUser :exec
UPDATE users SET
is_blocked = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) BlockUser(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, BlockUser, id)
return err
}
const GetUserProfile = `-- name: GetUserProfile :one
SELECT
id,
email,
phone,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at
FROM users
WHERE id = $1 AND deleted_at IS NULL
`
type GetUserProfileRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
}
// Фаза 1C: Управление профилем (КРИТИЧНО)
// Запросы для получения и обновления профилей пользователей
// ============================================================================
// Профиль пользователя
// ============================================================================
func (q *Queries) GetUserProfile(ctx context.Context, id int64) (GetUserProfileRow, error) {
row := q.db.QueryRow(ctx, GetUserProfile, id)
var i GetUserProfileRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.Latitude,
&i.Longitude,
&i.Address,
&i.City,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
&i.IsBlocked,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLoginAt,
)
return i, err
}
const GetUsersByIDs = `-- name: GetUsersByIDs :many
SELECT
id,
email,
phone,
password_hash,
first_name,
last_name,
avatar_url,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude,
address,
city,
volunteer_rating,
completed_requests_count,
is_verified,
is_blocked,
email_verified,
created_at,
updated_at,
last_login_at,
deleted_at
FROM users
WHERE id = ANY($1::bigint[])
AND deleted_at IS NULL
`
type GetUsersByIDsRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
Phone pgtype.Text `json:"phone"`
PasswordHash string `json:"password_hash"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Latitude interface{} `json:"latitude"`
Longitude interface{} `json:"longitude"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
IsBlocked pgtype.Bool `json:"is_blocked"`
EmailVerified pgtype.Bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
// ============================================================================
// Поиск пользователей
// ============================================================================
func (q *Queries) GetUsersByIDs(ctx context.Context, dollar_1 []int64) ([]GetUsersByIDsRow, error) {
rows, err := q.db.Query(ctx, GetUsersByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUsersByIDsRow{}
for rows.Next() {
var i GetUsersByIDsRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.PasswordHash,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.Latitude,
&i.Longitude,
&i.Address,
&i.City,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
&i.IsBlocked,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastLoginAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetVolunteerStatistics = `-- name: GetVolunteerStatistics :one
SELECT
id,
first_name,
last_name,
volunteer_rating,
completed_requests_count,
created_at as member_since
FROM users
WHERE id = $1
AND deleted_at IS NULL
`
type GetVolunteerStatisticsRow struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
MemberSince pgtype.Timestamptz `json:"member_since"`
}
func (q *Queries) GetVolunteerStatistics(ctx context.Context, id int64) (GetVolunteerStatisticsRow, error) {
row := q.db.QueryRow(ctx, GetVolunteerStatistics, id)
var i GetVolunteerStatisticsRow
err := row.Scan(
&i.ID,
&i.FirstName,
&i.LastName,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.MemberSince,
)
return i, err
}
const SearchUsersByName = `-- name: SearchUsersByName :many
SELECT
id,
email,
first_name,
last_name,
avatar_url,
volunteer_rating,
completed_requests_count,
is_verified
FROM users
WHERE (first_name ILIKE '%' || $1 || '%' OR last_name ILIKE '%' || $1 || '%' OR (first_name || ' ' || last_name) ILIKE '%' || $1 || '%')
AND deleted_at IS NULL
AND is_blocked = FALSE
ORDER BY volunteer_rating DESC NULLS LAST
LIMIT $2 OFFSET $3
`
type SearchUsersByNameParams struct {
Column1 pgtype.Text `json:"column_1"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type SearchUsersByNameRow struct {
ID int64 `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
IsVerified pgtype.Bool `json:"is_verified"`
}
func (q *Queries) SearchUsersByName(ctx context.Context, arg SearchUsersByNameParams) ([]SearchUsersByNameRow, error) {
rows, err := q.db.Query(ctx, SearchUsersByName, arg.Column1, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SearchUsersByNameRow{}
for rows.Next() {
var i SearchUsersByNameRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.VolunteerRating,
&i.CompletedRequestsCount,
&i.IsVerified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const SoftDeleteUser = `-- name: SoftDeleteUser :exec
UPDATE users SET
deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) SoftDeleteUser(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, SoftDeleteUser, id)
return err
}
const UnblockUser = `-- name: UnblockUser :exec
UPDATE users SET
is_blocked = FALSE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UnblockUser(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, UnblockUser, id)
return err
}
const UpdateUserLocation = `-- name: UpdateUserLocation :exec
UPDATE users SET
location = ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography,
address = COALESCE($4, address),
city = COALESCE($5, city),
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
type UpdateUserLocationParams struct {
ID int64 `json:"id"`
StMakepoint interface{} `json:"st_makepoint"`
StMakepoint_2 interface{} `json:"st_makepoint_2"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
}
func (q *Queries) UpdateUserLocation(ctx context.Context, arg UpdateUserLocationParams) error {
_, err := q.db.Exec(ctx, UpdateUserLocation,
arg.ID,
arg.StMakepoint,
arg.StMakepoint_2,
arg.Address,
arg.City,
)
return err
}
const UpdateUserPassword = `-- name: UpdateUserPassword :exec
UPDATE users SET
password_hash = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
type UpdateUserPasswordParams struct {
ID int64 `json:"id"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error {
_, err := q.db.Exec(ctx, UpdateUserPassword, arg.ID, arg.PasswordHash)
return err
}
const UpdateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users SET
phone = COALESCE($1, phone),
first_name = COALESCE($2, first_name),
last_name = COALESCE($3, last_name),
avatar_url = COALESCE($4, avatar_url),
address = COALESCE($5, address),
city = COALESCE($6, city),
updated_at = CURRENT_TIMESTAMP
WHERE id = $7
`
type UpdateUserProfileParams struct {
Phone pgtype.Text `json:"phone"`
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Address pgtype.Text `json:"address"`
City pgtype.Text `json:"city"`
UserID int64 `json:"user_id"`
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
_, err := q.db.Exec(ctx, UpdateUserProfile,
arg.Phone,
arg.FirstName,
arg.LastName,
arg.AvatarUrl,
arg.Address,
arg.City,
arg.UserID,
)
return err
}
const VerifyUserEmail = `-- name: VerifyUserEmail :exec
UPDATE users SET
email_verified = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) VerifyUserEmail(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, VerifyUserEmail, id)
return err
}

113
internal/pkg/jwt/jwt.go Normal file
View File

@@ -0,0 +1,113 @@
package jwt
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims содержит данные JWT токена
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// Manager управляет JWT токенами
type Manager struct {
secretKey string
accessTokenDuration time.Duration
refreshTokenDuration time.Duration
}
// NewManager создает новый JWT менеджер
func NewManager(secretKey string, accessDuration, refreshDuration time.Duration) *Manager {
return &Manager{
secretKey: secretKey,
accessTokenDuration: accessDuration,
refreshTokenDuration: refreshDuration,
}
}
// GenerateAccessToken генерирует access токен
func (m *Manager) GenerateAccessToken(userID int64, email string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.accessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(m.secretKey))
}
// GenerateRefreshToken генерирует refresh токен
func (m *Manager) GenerateRefreshToken(userID int64, email string) (string, error) {
// Генерируем уникальный ID для токена
jti, err := generateJTI()
if err != nil {
return "", fmt.Errorf("failed to generate jti: %w", err)
}
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.refreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(m.secretKey))
}
// generateJTI генерирует уникальный ID для JWT токена
func generateJTI() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// ValidateToken валидирует токен и возвращает claims
func (m *Manager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Проверяем метод подписи
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.secretKey), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
// GetExpirationTime возвращает время истечения access токена
func (m *Manager) GetAccessTokenDuration() time.Duration {
return m.accessTokenDuration
}
// GetRefreshTokenDuration возвращает время истечения refresh токена
func (m *Manager) GetRefreshTokenDuration() time.Duration {
return m.refreshTokenDuration
}

View File

@@ -0,0 +1,36 @@
package password
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
const (
// DefaultCost - стандартная стоимость хеширования bcrypt
DefaultCost = bcrypt.DefaultCost
)
// Hash хеширует пароль с использованием bcrypt
func Hash(password string) (string, error) {
if password == "" {
return "", fmt.Errorf("password cannot be empty")
}
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
return string(hashedBytes), nil
}
// Verify проверяет соответствие пароля хешу
func Verify(hashedPassword, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
// IsValid проверяет валидность пароля (минимальная длина)
func IsValid(password string) bool {
return len(password) >= 8
}

View File

@@ -0,0 +1,73 @@
package repository
import (
"context"
"git.kirlllll.ru/volontery/backend/internal/database"
)
// AuthRepository предоставляет методы для работы с аутентификацией
type AuthRepository struct {
queries *database.Queries
}
// NewAuthRepository создает новый AuthRepository
func NewAuthRepository(queries *database.Queries) *AuthRepository {
return &AuthRepository{queries: queries}
}
// CreateRefreshToken создает новый refresh токен
func (r *AuthRepository) CreateRefreshToken(ctx context.Context, params database.CreateRefreshTokenParams) (*database.RefreshToken, error) {
result, err := r.queries.CreateRefreshToken(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// GetRefreshToken получает refresh токен
func (r *AuthRepository) GetRefreshToken(ctx context.Context, token string) (*database.RefreshToken, error) {
result, err := r.queries.GetRefreshToken(ctx, token)
if err != nil {
return nil, err
}
return &result, nil
}
// RevokeRefreshToken отзывает refresh токен
func (r *AuthRepository) RevokeRefreshToken(ctx context.Context, id int64) error {
return r.queries.RevokeRefreshToken(ctx, id)
}
// RevokeAllUserTokens отзывает все токены пользователя
func (r *AuthRepository) RevokeAllUserTokens(ctx context.Context, userID int64) error {
return r.queries.RevokeAllUserTokens(ctx, userID)
}
// CreateUserSession создает новую сессию
func (r *AuthRepository) CreateUserSession(ctx context.Context, params database.CreateUserSessionParams) (*database.UserSession, error) {
result, err := r.queries.CreateUserSession(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// GetUserSession получает сессию по токену
func (r *AuthRepository) GetUserSession(ctx context.Context, sessionToken string) (*database.UserSession, error) {
result, err := r.queries.GetUserSession(ctx, sessionToken)
if err != nil {
return nil, err
}
return &result, nil
}
// UpdateSessionActivity обновляет время активности сессии
func (r *AuthRepository) UpdateSessionActivity(ctx context.Context, id int64) error {
return r.queries.UpdateSessionActivity(ctx, id)
}
// InvalidateUserSession удаляет сессию
func (r *AuthRepository) InvalidateUserSession(ctx context.Context, id int64) error {
return r.queries.InvalidateUserSession(ctx, id)
}

View File

@@ -0,0 +1,60 @@
package repository
import (
"context"
"git.kirlllll.ru/volontery/backend/internal/database"
)
// RBACRepository предоставляет методы для работы с RBAC
type RBACRepository struct {
queries *database.Queries
}
// NewRBACRepository создает новый RBACRepository
func NewRBACRepository(queries *database.Queries) *RBACRepository {
return &RBACRepository{queries: queries}
}
// GetUserRoles получает роли пользователя
func (r *RBACRepository) GetUserRoles(ctx context.Context, userID int64) ([]database.Role, error) {
return r.queries.GetUserRoles(ctx, userID)
}
// AssignRoleToUser назначает роль пользователю
func (r *RBACRepository) AssignRoleToUser(ctx context.Context, params database.AssignRoleToUserParams) (*database.UserRole, error) {
result, err := r.queries.AssignRoleToUser(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// GetRoleByName получает роль по имени
func (r *RBACRepository) GetRoleByName(ctx context.Context, name string) (*database.Role, error) {
result, err := r.queries.GetRoleByName(ctx, name)
if err != nil {
return nil, err
}
return &result, nil
}
// UserHasRole проверяет наличие роли у пользователя
func (r *RBACRepository) UserHasRole(ctx context.Context, params database.UserHasRoleParams) (bool, error) {
return r.queries.UserHasRole(ctx, params)
}
// UserHasRoleByName проверяет наличие роли по имени
func (r *RBACRepository) UserHasRoleByName(ctx context.Context, params database.UserHasRoleByNameParams) (bool, error) {
return r.queries.UserHasRoleByName(ctx, params)
}
// GetUserPermissions получает все разрешения пользователя
func (r *RBACRepository) GetUserPermissions(ctx context.Context, userID int64) ([]database.GetUserPermissionsRow, error) {
return r.queries.GetUserPermissions(ctx, userID)
}
// UserHasPermission проверяет наличие разрешения у пользователя
func (r *RBACRepository) UserHasPermission(ctx context.Context, params database.UserHasPermissionParams) (bool, error) {
return r.queries.UserHasPermission(ctx, params)
}

View File

@@ -0,0 +1,26 @@
package repository
import (
"git.kirlllll.ru/volontery/backend/internal/database"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository содержит все репозитории приложения
type Repository struct {
User *UserRepository
Auth *AuthRepository
Request *RequestRepository
RBAC *RBACRepository
}
// New создает новый экземпляр Repository
func New(pool *pgxpool.Pool) *Repository {
queries := database.New(pool)
return &Repository{
User: NewUserRepository(queries),
Auth: NewAuthRepository(queries),
Request: NewRequestRepository(queries),
RBAC: NewRBACRepository(queries),
}
}

View File

@@ -0,0 +1,165 @@
package repository
import (
"context"
"git.kirlllll.ru/volontery/backend/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// RequestRepository предоставляет методы для работы с заявками
type RequestRepository struct {
queries *database.Queries
}
// NewRequestRepository создает новый RequestRepository
func NewRequestRepository(queries *database.Queries) *RequestRepository {
return &RequestRepository{queries: queries}
}
// Create создает новую заявку
func (r *RequestRepository) Create(ctx context.Context, params database.CreateRequestParams) (*database.CreateRequestRow, error) {
result, err := r.queries.CreateRequest(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// GetByID получает заявку по ID
func (r *RequestRepository) GetByID(ctx context.Context, id int64) (*database.GetRequestByIDRow, error) {
result, err := r.queries.GetRequestByID(ctx, id)
if err != nil {
return nil, err
}
return &result, nil
}
// GetByRequester получает заявки пользователя
func (r *RequestRepository) GetByRequester(ctx context.Context, params database.GetRequestsByRequesterParams) ([]database.GetRequestsByRequesterRow, error) {
return r.queries.GetRequestsByRequester(ctx, params)
}
// UpdateStatus обновляет статус заявки
func (r *RequestRepository) UpdateStatus(ctx context.Context, params database.UpdateRequestStatusParams) error {
return r.queries.UpdateRequestStatus(ctx, params)
}
// Delete удаляет заявку (soft delete)
func (r *RequestRepository) Delete(ctx context.Context, params database.DeleteRequestParams) error {
return r.queries.DeleteRequest(ctx, params)
}
// ListTypes получает список типов заявок
func (r *RequestRepository) ListTypes(ctx context.Context) ([]database.RequestType, error) {
return r.queries.ListRequestTypes(ctx)
}
// FindNearby ищет заявки рядом с точкой
func (r *RequestRepository) FindNearby(ctx context.Context, params database.FindRequestsNearbyParams) ([]database.FindRequestsNearbyRow, error) {
return r.queries.FindRequestsNearby(ctx, params)
}
// FindInBounds ищет заявки в прямоугольной области
func (r *RequestRepository) FindInBounds(ctx context.Context, params database.FindRequestsInBoundsParams) ([]database.FindRequestsInBoundsRow, error) {
return r.queries.FindRequestsInBounds(ctx, params)
}
// CreateVolunteerResponse создает отклик волонтера
func (r *RequestRepository) CreateVolunteerResponse(ctx context.Context, params database.CreateVolunteerResponseParams) (*database.VolunteerResponse, error) {
result, err := r.queries.CreateVolunteerResponse(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// GetResponsesByRequest получает отклики на заявку
func (r *RequestRepository) GetResponsesByRequest(ctx context.Context, requestID int64) ([]database.GetResponsesByRequestRow, error) {
return r.queries.GetResponsesByRequest(ctx, requestID)
}
// GetPendingModerationRequests получает заявки на модерации
func (r *RequestRepository) GetPendingModerationRequests(ctx context.Context, limit, offset int32) ([]database.GetPendingModerationRequestsRow, error) {
return r.queries.GetPendingModerationRequests(ctx, database.GetPendingModerationRequestsParams{
Limit: limit,
Offset: offset,
})
}
// ApproveRequest одобряет заявку
func (r *RequestRepository) ApproveRequest(ctx context.Context, params database.ApproveRequestParams) error {
return r.queries.ApproveRequest(ctx, params)
}
// int64ToPgInt8 конвертирует int64 в pgtype.Int8
func int64ToPgInt8(i int64) pgtype.Int8 {
if i == 0 {
return pgtype.Int8{Valid: false}
}
return pgtype.Int8{Int64: i, Valid: true}
}
// RejectRequest отклоняет заявку
func (r *RequestRepository) RejectRequest(ctx context.Context, params database.RejectRequestParams) error {
return r.queries.RejectRequest(ctx, params)
}
// GetModeratedRequests получает заявки, модерированные указанным модератором
func (r *RequestRepository) GetModeratedRequests(ctx context.Context, moderatorID int64, limit, offset int32) ([]database.GetModeratedRequestsRow, error) {
return r.queries.GetModeratedRequests(ctx, database.GetModeratedRequestsParams{
ModeratedBy: int64ToPgInt8(moderatorID),
Limit: limit,
Offset: offset,
})
}
// AcceptVolunteerResponse вызывает хранимую процедуру для принятия отклика
func (r *RequestRepository) AcceptVolunteerResponse(ctx context.Context, responseID, requesterID int64) (*database.CallAcceptVolunteerResponseRow, error) {
result, err := r.queries.CallAcceptVolunteerResponse(ctx, database.CallAcceptVolunteerResponseParams{
PResponseID: responseID,
PRequesterID: requesterID,
})
if err != nil {
return nil, err
}
return &result, nil
}
// CompleteRequestWithRating вызывает хранимую процедуру для завершения заявки с рейтингом
func (r *RequestRepository) CompleteRequestWithRating(ctx context.Context, requestID, requesterID int64, rating int32, comment *string) (*database.CallCompleteRequestWithRatingRow, error) {
params := database.CallCompleteRequestWithRatingParams{
PRequestID: requestID,
PRequesterID: requesterID,
PRating: rating,
}
if comment != nil {
params.Comment = pgtype.Text{String: *comment, Valid: true}
}
result, err := r.queries.CallCompleteRequestWithRating(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// ModerateRequestProcedure вызывает хранимую процедуру для модерации заявки
func (r *RequestRepository) ModerateRequestProcedure(ctx context.Context, requestID, moderatorID int64, action string, comment *string) (*database.CallModerateRequestRow, error) {
params := database.CallModerateRequestParams{
PRequestID: requestID,
PModeratorID: moderatorID,
PAction: action,
}
if comment != nil {
params.Comment = pgtype.Text{String: *comment, Valid: true}
}
result, err := r.queries.CallModerateRequest(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -0,0 +1,78 @@
package repository
import (
"context"
"git.kirlllll.ru/volontery/backend/internal/database"
)
// UserRepository предоставляет методы для работы с пользователями
type UserRepository struct {
queries *database.Queries
}
// NewUserRepository создает новый UserRepository
func NewUserRepository(queries *database.Queries) *UserRepository {
return &UserRepository{queries: queries}
}
// GetByID получает пользователя по ID
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*database.GetUserByIDRow, error) {
result, err := r.queries.GetUserByID(ctx, id)
if err != nil {
return nil, err
}
return &result, nil
}
// GetByEmail получает пользователя по email
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*database.GetUserByEmailRow, error) {
result, err := r.queries.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
return &result, nil
}
// Create создает нового пользователя
func (r *UserRepository) Create(ctx context.Context, params database.CreateUserParams) (*database.CreateUserRow, error) {
result, err := r.queries.CreateUser(ctx, params)
if err != nil {
return nil, err
}
return &result, nil
}
// EmailExists проверяет существование email
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
return r.queries.EmailExists(ctx, email)
}
// UpdateLastLogin обновляет время последнего входа
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id int64) error {
return r.queries.UpdateLastLogin(ctx, id)
}
// GetProfile получает профиль пользователя
func (r *UserRepository) GetProfile(ctx context.Context, id int64) (*database.GetUserProfileRow, error) {
result, err := r.queries.GetUserProfile(ctx, id)
if err != nil {
return nil, err
}
return &result, nil
}
// UpdateProfile обновляет профиль пользователя
func (r *UserRepository) UpdateProfile(ctx context.Context, params database.UpdateUserProfileParams) error {
return r.queries.UpdateUserProfile(ctx, params)
}
// UpdateLocation обновляет геолокацию пользователя
func (r *UserRepository) UpdateLocation(ctx context.Context, params database.UpdateUserLocationParams) error {
return r.queries.UpdateUserLocation(ctx, params)
}
// VerifyEmail подтверждает email пользователя
func (r *UserRepository) VerifyEmail(ctx context.Context, id int64) error {
return r.queries.VerifyUserEmail(ctx, id)
}

View File

@@ -0,0 +1,246 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
"git.kirlllll.ru/volontery/backend/internal/pkg/password"
"git.kirlllll.ru/volontery/backend/internal/repository"
)
// AuthService предоставляет методы для аутентификации
type AuthService struct {
userRepo *repository.UserRepository
authRepo *repository.AuthRepository
rbacRepo *repository.RBACRepository
jwtMgr *jwt.Manager
}
// NewAuthService создает новый AuthService
func NewAuthService(
userRepo *repository.UserRepository,
authRepo *repository.AuthRepository,
rbacRepo *repository.RBACRepository,
jwtMgr *jwt.Manager,
) *AuthService {
return &AuthService{
userRepo: userRepo,
authRepo: authRepo,
rbacRepo: rbacRepo,
jwtMgr: jwtMgr,
}
}
// RegisterRequest - запрос на регистрацию
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
Bio string `json:"bio,omitempty"`
}
// LoginRequest - запрос на вход
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// AuthResponse - ответ с токенами
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User *UserInfo `json:"user"`
}
// UserInfo - информация о пользователе
type UserInfo struct {
ID int64 `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Verified bool `json:"email_verified"`
}
// Register регистрирует нового пользователя
func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
// Валидация
if req.Email == "" {
return nil, fmt.Errorf("email is required")
}
if !password.IsValid(req.Password) {
return nil, fmt.Errorf("password must be at least 8 characters")
}
if req.FirstName == "" || req.LastName == "" {
return nil, fmt.Errorf("first name and last name are required")
}
// Проверка существования email
exists, err := s.userRepo.EmailExists(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check email: %w", err)
}
if exists {
return nil, fmt.Errorf("email already registered")
}
// Хеширование пароля
hashedPassword, err := password.Hash(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Создание пользователя
user, err := s.userRepo.Create(ctx, database.CreateUserParams{
Email: req.Email,
PasswordHash: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
Phone: stringToPgText(req.Phone),
StMakepoint: req.Longitude,
StMakepoint_2: req.Latitude,
Address: stringToPgText(req.Address),
City: stringToPgText(req.City),
})
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Назначение роли "requester" по умолчанию
requesterRole, err := s.rbacRepo.GetRoleByName(ctx, "requester")
if err == nil {
_, _ = s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
UserID: user.ID,
RoleID: requesterRole.ID,
AssignedBy: int64ToPgInt8(user.ID), // сам себе назначил
})
}
// Генерация токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// Login выполняет вход пользователя
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
// Получение пользователя
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("invalid email or password")
}
// Проверка пароля
if err := password.Verify(user.PasswordHash, req.Password); err != nil {
return nil, fmt.Errorf("invalid email or password")
}
// Проверка блокировки
if user.IsBlocked.Bool {
return nil, fmt.Errorf("user account is blocked")
}
// Обновление времени последнего входа
_ = s.userRepo.UpdateLastLogin(ctx, user.ID)
// Генерация токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// RefreshTokens обновляет токены
func (s *AuthService) RefreshTokens(ctx context.Context, refreshTokenString string) (*AuthResponse, error) {
// Валидация refresh токена
claims, err := s.jwtMgr.ValidateToken(refreshTokenString)
if err != nil {
return nil, fmt.Errorf("invalid refresh token")
}
// Проверка токена в БД
storedToken, err := s.authRepo.GetRefreshToken(ctx, refreshTokenString)
if err != nil {
return nil, fmt.Errorf("refresh token not found or expired")
}
// Отзыв старого токена
_ = s.authRepo.RevokeRefreshToken(ctx, storedToken.ID)
// Получение пользователя
user, err := s.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if user.IsBlocked.Bool {
return nil, fmt.Errorf("user account is blocked")
}
// Генерация новых токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// Logout выход пользователя
func (s *AuthService) Logout(ctx context.Context, userID int64) error {
return s.authRepo.RevokeAllUserTokens(ctx, userID)
}
// generateTokens генерирует access и refresh токены
func (s *AuthService) generateTokens(ctx context.Context, userID int64, email, userAgent, ipAddress string) (*AuthResponse, error) {
// Генерация access токена
accessToken, err := s.jwtMgr.GenerateAccessToken(userID, email)
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
// Генерация refresh токена
refreshToken, err := s.jwtMgr.GenerateRefreshToken(userID, email)
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
// Сохранение refresh токена в БД
expiresAt := time.Now().Add(s.jwtMgr.GetRefreshTokenDuration())
_, err = s.authRepo.CreateRefreshToken(ctx, database.CreateRefreshTokenParams{
UserID: userID,
Token: refreshToken,
ExpiresAt: timeToPgTimestamptz(expiresAt),
UserAgent: stringToPgText(userAgent),
IpAddress: nil, // IP адрес не передается в текущей реализации
})
if err != nil {
return nil, fmt.Errorf("failed to save refresh token: %w", err)
}
// Получение информации о пользователе
user, _ := s.userRepo.GetByID(ctx, userID)
return &AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(s.jwtMgr.GetAccessTokenDuration().Seconds()),
User: &UserInfo{
ID: userID,
Email: email,
FirstName: user.FirstName,
LastName: user.LastName,
Verified: user.EmailVerified.Bool,
},
}, nil
}
// generateRandomToken генерирует случайный токен
func generateRandomToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,27 @@
package service
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// Helper functions для конвертации типов Go в pgtype
func stringToPgText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func int64ToPgInt8(i int64) pgtype.Int8 {
if i == 0 {
return pgtype.Int8{Valid: false}
}
return pgtype.Int8{Int64: i, Valid: true}
}
func timeToPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}

View File

@@ -0,0 +1,202 @@
package service
import (
"context"
"fmt"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/repository"
"github.com/jackc/pgx/v5/pgtype"
)
// RequestService предоставляет методы для работы с заявками
type RequestService struct {
requestRepo *repository.RequestRepository
}
// NewRequestService создает новый RequestService
func NewRequestService(requestRepo *repository.RequestRepository) *RequestService {
return &RequestService{
requestRepo: requestRepo,
}
}
// CreateRequestInput - входные данные для создания заявки
type CreateRequestInput struct {
RequesterID int64 `json:"requester_id"`
RequestTypeID int64 `json:"request_type_id"`
Title string `json:"title"`
Description string `json:"description"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Address string `json:"address"`
City string `json:"city,omitempty"`
DesiredCompletionDate *string `json:"desired_completion_date,omitempty"`
Urgency string `json:"urgency"`
ContactPhone string `json:"contact_phone,omitempty"`
ContactNotes string `json:"contact_notes,omitempty"`
}
// CreateRequest создает новую заявку
func (s *RequestService) CreateRequest(ctx context.Context, input CreateRequestInput) (*database.CreateRequestRow, error) {
// Валидация
if input.Title == "" {
return nil, fmt.Errorf("title is required")
}
if input.Description == "" {
return nil, fmt.Errorf("description is required")
}
if input.Latitude == 0 || input.Longitude == 0 {
return nil, fmt.Errorf("location is required")
}
// Создание заявки
return s.requestRepo.Create(ctx, database.CreateRequestParams{
RequesterID: input.RequesterID,
RequestTypeID: input.RequestTypeID,
Title: input.Title,
Description: input.Description,
StMakepoint: input.Longitude,
StMakepoint_2: input.Latitude,
Address: input.Address,
City: stringToPgText(input.City),
Urgency: stringToPgText(input.Urgency),
ContactPhone: stringToPgText(input.ContactPhone),
ContactNotes: stringToPgText(input.ContactNotes),
})
}
// GetRequest получает заявку по ID
func (s *RequestService) GetRequest(ctx context.Context, id int64) (*database.GetRequestByIDRow, error) {
return s.requestRepo.GetByID(ctx, id)
}
// GetUserRequests получает заявки пользователя
func (s *RequestService) GetUserRequests(ctx context.Context, userID int64, limit, offset int32) ([]database.GetRequestsByRequesterRow, error) {
return s.requestRepo.GetByRequester(ctx, database.GetRequestsByRequesterParams{
RequesterID: userID,
Limit: limit,
Offset: offset,
})
}
// FindNearbyRequests ищет заявки рядом с точкой
func (s *RequestService) FindNearbyRequests(ctx context.Context, lat, lon float64, radiusMeters float64, statuses []database.RequestStatus, limit, offset int32) ([]database.FindRequestsNearbyRow, error) {
// Конвертируем []RequestStatus в []string
statusStrings := make([]string, len(statuses))
for i, status := range statuses {
statusStrings[i] = string(status)
}
return s.requestRepo.FindNearby(ctx, database.FindRequestsNearbyParams{
StMakepoint: lon,
StMakepoint_2: lat,
Column3: statusStrings,
StDwithin: radiusMeters,
Limit: limit,
Offset: offset,
})
}
// FindRequestsInBounds ищет заявки в прямоугольной области (для карты)
func (s *RequestService) FindRequestsInBounds(ctx context.Context, statuses []database.RequestStatus, minLon, minLat, maxLon, maxLat float64) ([]database.FindRequestsInBoundsRow, error) {
// Конвертируем []RequestStatus в []string
statusStrings := make([]string, len(statuses))
for i, status := range statuses {
statusStrings[i] = string(status)
}
return s.requestRepo.FindInBounds(ctx, database.FindRequestsInBoundsParams{
Column1: statusStrings,
StMakeenvelope: minLon,
StMakeenvelope_2: minLat,
StMakeenvelope_3: maxLon,
StMakeenvelope_4: maxLat,
})
}
// CreateVolunteerResponse создает отклик волонтера на заявку
func (s *RequestService) CreateVolunteerResponse(ctx context.Context, requestID, volunteerID int64, message string) (*database.VolunteerResponse, error) {
return s.requestRepo.CreateVolunteerResponse(ctx, database.CreateVolunteerResponseParams{
RequestID: requestID,
VolunteerID: volunteerID,
Message: stringToPgText(message),
})
}
// GetRequestResponses получает отклики на заявку
func (s *RequestService) GetRequestResponses(ctx context.Context, requestID int64) ([]database.GetResponsesByRequestRow, error) {
return s.requestRepo.GetResponsesByRequest(ctx, requestID)
}
// ListRequestTypes получает список типов заявок
func (s *RequestService) ListRequestTypes(ctx context.Context) ([]database.RequestType, error) {
return s.requestRepo.ListTypes(ctx)
}
// GetPendingModerationRequests получает заявки на модерации
func (s *RequestService) GetPendingModerationRequests(ctx context.Context, limit, offset int32) ([]database.GetPendingModerationRequestsRow, error) {
return s.requestRepo.GetPendingModerationRequests(ctx, limit, offset)
}
// ApproveRequest одобряет заявку
func (s *RequestService) ApproveRequest(ctx context.Context, requestID, moderatorID int64, comment *string) error {
moderationComment := stringToPgText("")
if comment != nil {
moderationComment = stringToPgText(*comment)
}
return s.requestRepo.ApproveRequest(ctx, database.ApproveRequestParams{
ID: requestID,
ModeratedBy: pgtype.Int8{
Int64: moderatorID,
Valid: true,
},
ModerationComment: moderationComment,
})
}
// RejectRequest отклоняет заявку
func (s *RequestService) RejectRequest(ctx context.Context, requestID, moderatorID int64, comment string) error {
if comment == "" {
return fmt.Errorf("rejection comment is required")
}
return s.requestRepo.RejectRequest(ctx, database.RejectRequestParams{
ID: requestID,
ModeratedBy: pgtype.Int8{
Int64: moderatorID,
Valid: true,
},
ModerationComment: stringToPgText(comment),
})
}
// GetModeratedRequests получает заявки, модерированные указанным модератором
func (s *RequestService) GetModeratedRequests(ctx context.Context, moderatorID int64, limit, offset int32) ([]database.GetModeratedRequestsRow, error) {
return s.requestRepo.GetModeratedRequests(ctx, moderatorID, limit, offset)
}
// AcceptVolunteerResponse принимает отклик волонтера через хранимую процедуру
func (s *RequestService) AcceptVolunteerResponse(ctx context.Context, responseID, requesterID int64) (*database.CallAcceptVolunteerResponseRow, error) {
return s.requestRepo.AcceptVolunteerResponse(ctx, responseID, requesterID)
}
// CompleteRequestWithRating завершает заявку с рейтингом через хранимую процедуру
func (s *RequestService) CompleteRequestWithRating(ctx context.Context, requestID, requesterID int64, rating int32, comment *string) (*database.CallCompleteRequestWithRatingRow, error) {
if rating < 1 || rating > 5 {
return nil, fmt.Errorf("rating must be between 1 and 5")
}
return s.requestRepo.CompleteRequestWithRating(ctx, requestID, requesterID, rating, comment)
}
// ModerateRequestProcedure модерирует заявку через хранимую процедуру
func (s *RequestService) ModerateRequestProcedure(ctx context.Context, requestID, moderatorID int64, action string, comment *string) (*database.CallModerateRequestRow, error) {
if action != "approve" && action != "reject" {
return nil, fmt.Errorf("action must be 'approve' or 'reject'")
}
if action == "reject" && (comment == nil || *comment == "") {
return nil, fmt.Errorf("comment is required when rejecting")
}
return s.requestRepo.ModerateRequestProcedure(ctx, requestID, moderatorID, action, comment)
}

View File

@@ -0,0 +1,96 @@
package service
import (
"context"
"fmt"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/repository"
)
// UserService предоставляет методы для работы с пользователями
type UserService struct {
userRepo *repository.UserRepository
rbacRepo *repository.RBACRepository
}
// NewUserService создает новый UserService
func NewUserService(userRepo *repository.UserRepository, rbacRepo *repository.RBACRepository) *UserService {
return &UserService{
userRepo: userRepo,
rbacRepo: rbacRepo,
}
}
// GetUserProfile получает профиль пользователя
func (s *UserService) GetUserProfile(ctx context.Context, userID int64) (*database.GetUserProfileRow, error) {
return s.userRepo.GetProfile(ctx, userID)
}
// UpdateProfileInput - входные данные для обновления профиля
type UpdateProfileInput struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Phone string `json:"phone,omitempty"`
Bio string `json:"bio,omitempty"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
}
// UpdateUserProfile обновляет профиль пользователя
func (s *UserService) UpdateUserProfile(ctx context.Context, userID int64, input UpdateProfileInput) error {
return s.userRepo.UpdateProfile(ctx, database.UpdateUserProfileParams{
UserID: userID,
FirstName: stringToPgText(input.FirstName),
LastName: stringToPgText(input.LastName),
Phone: stringToPgText(input.Phone),
Address: stringToPgText(input.Address),
City: stringToPgText(input.City),
})
}
// UpdateUserLocation обновляет местоположение пользователя
func (s *UserService) UpdateUserLocation(ctx context.Context, userID int64, lat, lon float64) error {
if lat == 0 || lon == 0 {
return fmt.Errorf("invalid coordinates")
}
return s.userRepo.UpdateLocation(ctx, database.UpdateUserLocationParams{
ID: userID,
StMakepoint: lon,
StMakepoint_2: lat,
})
}
// VerifyEmail подтверждает email пользователя
func (s *UserService) VerifyEmail(ctx context.Context, userID int64) error {
return s.userRepo.VerifyEmail(ctx, userID)
}
// GetUserRoles получает роли пользователя
func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]database.Role, error) {
return s.rbacRepo.GetUserRoles(ctx, userID)
}
// GetUserPermissions получает разрешения пользователя
func (s *UserService) GetUserPermissions(ctx context.Context, userID int64) ([]database.GetUserPermissionsRow, error) {
return s.rbacRepo.GetUserPermissions(ctx, userID)
}
// HasPermission проверяет наличие разрешения у пользователя
func (s *UserService) HasPermission(ctx context.Context, userID int64, permissionName string) (bool, error) {
return s.rbacRepo.UserHasPermission(ctx, database.UserHasPermissionParams{
ID: userID,
Name: permissionName,
})
}
// AssignRole назначает роль пользователю
func (s *UserService) AssignRole(ctx context.Context, userID, roleID, assignedBy int64) error {
_, err := s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
UserID: userID,
RoleID: roleID,
AssignedBy: int64ToPgInt8(assignedBy),
})
return err
}