initial commit
This commit is contained in:
117
internal/api/handlers/auth.go
Normal file
117
internal/api/handlers/auth.go
Normal 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,
|
||||
})
|
||||
}
|
||||
28
internal/api/handlers/helpers.go
Normal file
28
internal/api/handlers/helpers.go
Normal 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})
|
||||
}
|
||||
460
internal/api/handlers/requests.go
Normal file
460
internal/api/handlers/requests.go
Normal 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
|
||||
}
|
||||
308
internal/api/handlers/users.go
Normal file
308
internal/api/handlers/users.go
Normal 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
25
internal/api/helpers.go
Normal 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)
|
||||
}
|
||||
107
internal/api/middleware/auth.go
Normal file
107
internal/api/middleware/auth.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
94
internal/api/middleware/common.go
Normal file
94
internal/api/middleware/common.go
Normal 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)
|
||||
})
|
||||
}
|
||||
106
internal/api/middleware/rbac.go
Normal file
106
internal/api/middleware/rbac.go
Normal 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
140
internal/api/router.go
Normal 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
139
internal/config/config.go
Normal 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
|
||||
}
|
||||
548
internal/database/auth.sql.go
Normal file
548
internal/database/auth.sql.go
Normal 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
32
internal/database/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
43
internal/database/geography.go
Normal file
43
internal/database/geography.go
Normal 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,
|
||||
}
|
||||
}
|
||||
412
internal/database/geospatial.sql.go
Normal file
412
internal/database/geospatial.sql.go
Normal 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
530
internal/database/models.go
Normal 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"`
|
||||
}
|
||||
185
internal/database/querier.go
Normal file
185
internal/database/querier.go
Normal 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)
|
||||
194
internal/database/queries/auth.sql
Normal file
194
internal/database/queries/auth.sql
Normal 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';
|
||||
151
internal/database/queries/geospatial.sql
Normal file
151
internal/database/queries/geospatial.sql
Normal 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;
|
||||
102
internal/database/queries/rbac.sql
Normal file
102
internal/database/queries/rbac.sql
Normal 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
|
||||
);
|
||||
339
internal/database/queries/requests.sql
Normal file
339
internal/database/queries/requests.sql
Normal 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;
|
||||
192
internal/database/queries/responses.sql
Normal file
192
internal/database/queries/responses.sql
Normal 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);
|
||||
137
internal/database/queries/users.sql
Normal file
137
internal/database/queries/users.sql
Normal 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;
|
||||
352
internal/database/rbac.sql.go
Normal file
352
internal/database/rbac.sql.go
Normal 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
|
||||
}
|
||||
1030
internal/database/requests.sql.go
Normal file
1030
internal/database/requests.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
713
internal/database/responses.sql.go
Normal file
713
internal/database/responses.sql.go
Normal 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
|
||||
}
|
||||
413
internal/database/users.sql.go
Normal file
413
internal/database/users.sql.go
Normal 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
113
internal/pkg/jwt/jwt.go
Normal 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
|
||||
}
|
||||
36
internal/pkg/password/password.go
Normal file
36
internal/pkg/password/password.go
Normal 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
|
||||
}
|
||||
73
internal/repository/auth_repository.go
Normal file
73
internal/repository/auth_repository.go
Normal 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)
|
||||
}
|
||||
60
internal/repository/rbac_repository.go
Normal file
60
internal/repository/rbac_repository.go
Normal 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)
|
||||
}
|
||||
26
internal/repository/repository.go
Normal file
26
internal/repository/repository.go
Normal 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),
|
||||
}
|
||||
}
|
||||
165
internal/repository/request_repository.go
Normal file
165
internal/repository/request_repository.go
Normal 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
|
||||
}
|
||||
78
internal/repository/user_repository.go
Normal file
78
internal/repository/user_repository.go
Normal 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)
|
||||
}
|
||||
246
internal/service/auth_service.go
Normal file
246
internal/service/auth_service.go
Normal 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
|
||||
}
|
||||
27
internal/service/helpers.go
Normal file
27
internal/service/helpers.go
Normal 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}
|
||||
}
|
||||
202
internal/service/request_service.go
Normal file
202
internal/service/request_service.go
Normal 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)
|
||||
}
|
||||
96
internal/service/user_service.go
Normal file
96
internal/service/user_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user