588 lines
17 KiB
Go
588 lines
17 KiB
Go
package pf
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||
)
|
||
|
||
type Server struct {
|
||
App *App
|
||
}
|
||
|
||
type listingPayload struct {
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
type bulkPayload struct {
|
||
URLs []string `json:"urls"`
|
||
}
|
||
|
||
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
path := s.apiPath(r.URL.Path)
|
||
switch {
|
||
case path == "/healthz":
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
case path == "/":
|
||
writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"})
|
||
case !strings.HasPrefix(path, "/api/v1"):
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
case !s.checkInternalAuth(w, r):
|
||
return
|
||
case path == "/api/v1/access/me" && r.Method == http.MethodGet:
|
||
s.accessMe(w, r)
|
||
case path == "/api/v1/summary" && r.Method == http.MethodGet:
|
||
s.summary(w, r)
|
||
case path == "/api/v1/team/overview" && r.Method == http.MethodGet:
|
||
s.teamOverview(w, r)
|
||
case path == "/api/v1/employees":
|
||
s.employees(w, r)
|
||
case strings.HasPrefix(path, "/api/v1/employees/"):
|
||
s.employeeItem(w, r, path)
|
||
case path == "/api/v1/projects":
|
||
s.projects(w, r)
|
||
case strings.HasPrefix(path, "/api/v1/projects/"):
|
||
s.projectItem(w, r, path)
|
||
case strings.HasPrefix(path, "/api/v1/listings/"):
|
||
s.listingItem(w, r, path)
|
||
default:
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
}
|
||
}
|
||
|
||
func (s Server) checkInternalAuth(w http.ResponseWriter, r *http.Request) bool {
|
||
want := strings.TrimSpace(s.App.Cfg.InternalAPIKey)
|
||
if want == "" {
|
||
return true
|
||
}
|
||
if r.Header.Get("X-Internal-Key") != want {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s Server) apiPath(path string) string {
|
||
base := s.App.Cfg.PublicBasePath
|
||
if base != "" && path == base {
|
||
return "/"
|
||
}
|
||
if base != "" && strings.HasPrefix(path, base+"/") {
|
||
return strings.TrimPrefix(path, base)
|
||
}
|
||
return path
|
||
}
|
||
|
||
func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
|
||
portalID := portalUserID(r)
|
||
emp, err := s.App.CurrentEmployee(r.Context(), portalID, false)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
botUsername := s.App.Cfg.TGBotUsername
|
||
if botUsername == "" {
|
||
if resolved, err := s.App.TG.BotUsername(r.Context()); err == nil {
|
||
botUsername = resolved
|
||
}
|
||
}
|
||
var link *string
|
||
if botUsername != "" && portalID != "" {
|
||
v := "https://t.me/" + botUsername + "?start=" + portalID
|
||
link = &v
|
||
}
|
||
var command *string
|
||
if portalID != "" {
|
||
v := "/start " + portalID
|
||
command = &v
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"is_admin": isAdmin(r),
|
||
"portal_user_id": nullablePlain(portalID),
|
||
"telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "",
|
||
"can_view_team": canViewTeam(r),
|
||
"employee": emp,
|
||
"telegram_bot_username": nullablePlain(botUsername),
|
||
"telegram_start_command": command,
|
||
"telegram_start_link": link,
|
||
})
|
||
}
|
||
|
||
func (s Server) summary(w http.ResponseWriter, r *http.Request) {
|
||
var emp *Employee
|
||
var err error
|
||
if requested := ownerPortalIDFromQuery(r); requested != nil {
|
||
var ok bool
|
||
emp, ok = s.resolveProjectOwnerForRead(w, r, requested)
|
||
if !ok {
|
||
return
|
||
}
|
||
} else {
|
||
emp, err = s.App.CurrentEmployee(r.Context(), portalUserID(r), false)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if emp != nil && emp.TGChatID == nil {
|
||
emp = nil
|
||
}
|
||
}
|
||
out, err := s.App.Summary(r.Context(), emp)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
func (s Server) teamOverview(w http.ResponseWriter, r *http.Request) {
|
||
if !canViewTeam(r) {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return
|
||
}
|
||
items, err := s.App.TeamOverview(r.Context(), subordinatePortalIDs(r), isAdmin(r))
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, items)
|
||
}
|
||
|
||
func (s Server) employees(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), false)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
var items []Employee
|
||
if isAdmin(r) {
|
||
items, err = s.App.ListEmployees(r.Context(), true, emp)
|
||
} else if canViewTeam(r) {
|
||
ids := append(subordinatePortalIDs(r), portalUserID(r))
|
||
items, err = s.App.ListEmployeesByPortalUserIDs(r.Context(), ids)
|
||
} else {
|
||
items, err = s.App.ListEmployees(r.Context(), false, emp)
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, items)
|
||
case http.MethodPost:
|
||
if !isAdmin(r) {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return
|
||
}
|
||
var payload EmployeePayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
emp, err := s.App.CreateEmployee(r.Context(), payload)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, emp)
|
||
default:
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
}
|
||
}
|
||
|
||
func (s Server) employeeItem(w http.ResponseWriter, r *http.Request, path string) {
|
||
id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/employees/"))
|
||
if !ok {
|
||
return
|
||
}
|
||
if !isAdmin(r) {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodPatch:
|
||
var payload EmployeePayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
emp, err := s.App.UpdateEmployee(r.Context(), id, payload)
|
||
if err != nil {
|
||
status := http.StatusBadRequest
|
||
if errors.Is(err, ErrNotFound) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeError(w, status, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, emp)
|
||
case http.MethodDelete:
|
||
err := s.App.DeleteEmployee(r.Context(), id)
|
||
if err != nil {
|
||
status := http.StatusBadRequest
|
||
if errors.Is(err, ErrNotFound) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeError(w, status, err.Error())
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
default:
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
}
|
||
}
|
||
|
||
func (s Server) projects(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
var emp *Employee
|
||
var ok bool
|
||
if requested := ownerPortalIDFromQuery(r); requested != nil {
|
||
emp, ok = s.resolveProjectOwnerForRead(w, r, requested)
|
||
} else {
|
||
emp, ok = s.requireEmployee(w, r)
|
||
}
|
||
if !ok {
|
||
return
|
||
}
|
||
items, err := s.App.ListProjects(r.Context(), emp.ID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, items)
|
||
case http.MethodPost:
|
||
var payload ProjectPayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
owner, ok := s.resolveProjectOwner(w, r, payload.OwnerPortalUserID)
|
||
if !ok {
|
||
return
|
||
}
|
||
project, err := s.App.CreateProject(r.Context(), owner.ID, payload)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, project)
|
||
default:
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
}
|
||
}
|
||
|
||
func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) {
|
||
rest := strings.TrimPrefix(path, "/api/v1/projects/")
|
||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||
if len(parts) == 0 {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return
|
||
}
|
||
projectID, ok := pathID(w, parts[0])
|
||
if !ok {
|
||
return
|
||
}
|
||
ownerID, ok := s.projectOwnerIDForAccess(w, r, projectID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
s.projectCRUD(w, r, ownerID, projectID)
|
||
return
|
||
}
|
||
switch {
|
||
case len(parts) == 2 && parts[1] == "check" && r.Method == http.MethodPost:
|
||
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
changes, err := s.App.Worker.CheckProject(r.Context(), projectID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int{"changes": changes})
|
||
case len(parts) == 2 && parts[1] == "suggest" && r.Method == http.MethodGet:
|
||
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
out, err := s.App.Worker.Suggest(r.Context(), projectID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost:
|
||
s.addListing(w, r, ownerID, projectID)
|
||
case len(parts) == 3 && parts[1] == "listings" && parts[2] == "bulk" && r.Method == http.MethodPost:
|
||
s.addListings(w, r, ownerID, projectID)
|
||
default:
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
}
|
||
}
|
||
|
||
func (s Server) projectCRUD(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
project, err := s.App.ProjectByID(r.Context(), ownerID, projectID, true)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, project)
|
||
case http.MethodPatch:
|
||
var payload ProjectPayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
project, err := s.App.UpdateProject(r.Context(), ownerID, projectID, payload)
|
||
if err != nil {
|
||
status := http.StatusBadRequest
|
||
if errors.Is(err, ErrNotFound) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeError(w, status, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, project)
|
||
case http.MethodDelete:
|
||
if err := s.App.DeleteProject(r.Context(), ownerID, projectID); err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
default:
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
}
|
||
}
|
||
|
||
func (s Server) addListing(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
|
||
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
var payload listingPayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
id, err := s.App.Worker.AddListing(r.Context(), projectID, payload.URL)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
listing, err := s.App.ListingByID(r.Context(), id, true)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, listing)
|
||
}
|
||
|
||
func (s Server) addListings(w http.ResponseWriter, r *http.Request, ownerID, projectID int64) {
|
||
if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return
|
||
}
|
||
var payload bulkPayload
|
||
if !decodeJSON(w, r, &payload) {
|
||
return
|
||
}
|
||
out, err := s.App.Worker.AddListings(r.Context(), projectID, payload.URLs)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) {
|
||
id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/"))
|
||
if !ok {
|
||
return
|
||
}
|
||
if r.Method != http.MethodDelete {
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
ownerID, ok := s.listingOwnerIDForAccess(w, r, id)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.App.DeleteListing(r.Context(), ownerID, id); err != nil {
|
||
writeError(w, http.StatusNotFound, "listing not found")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) {
|
||
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
|
||
if errors.Is(err, ErrTelegramRequired) {
|
||
writeError(w, http.StatusForbidden, "Сначала авторизуйтесь в Telegram-боте Monitoring PF")
|
||
return nil, false
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return nil, false
|
||
}
|
||
return emp, true
|
||
}
|
||
|
||
func (s Server) resolveProjectOwner(w http.ResponseWriter, r *http.Request, requested *string) (*Employee, bool) {
|
||
targetPortalID := strings.TrimSpace(portalUserID(r))
|
||
if requested != nil && strings.TrimSpace(*requested) != "" {
|
||
targetPortalID = strings.TrimSpace(*requested)
|
||
}
|
||
if targetPortalID == "" {
|
||
writeError(w, http.StatusForbidden, "Сначала авторизуйтесь в Telegram-боте Monitoring PF")
|
||
return nil, false
|
||
}
|
||
if !canManagePortalUser(r, targetPortalID) {
|
||
writeError(w, http.StatusForbidden, "Нет прав на создание проекта для этого сотрудника")
|
||
return nil, false
|
||
}
|
||
owner, err := s.App.CurrentEmployee(r.Context(), targetPortalID, true)
|
||
if errors.Is(err, ErrTelegramRequired) {
|
||
writeError(w, http.StatusBadRequest, "У владельца проекта не подключен Telegram-бот Monitoring PF")
|
||
return nil, false
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return nil, false
|
||
}
|
||
return owner, true
|
||
}
|
||
|
||
func (s Server) resolveProjectOwnerForRead(w http.ResponseWriter, r *http.Request, requested *string) (*Employee, bool) {
|
||
if requested == nil || strings.TrimSpace(*requested) == "" {
|
||
return s.requireEmployee(w, r)
|
||
}
|
||
targetPortalID := strings.TrimSpace(*requested)
|
||
if !canManagePortalUser(r, targetPortalID) {
|
||
writeError(w, http.StatusForbidden, "Нет прав на просмотр объектов этого сотрудника")
|
||
return nil, false
|
||
}
|
||
owner, err := s.App.EmployeeByPortalUserID(r.Context(), targetPortalID)
|
||
if errors.Is(err, ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||
writeError(w, http.StatusNotFound, "employee not found")
|
||
return nil, false
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return nil, false
|
||
}
|
||
return owner, true
|
||
}
|
||
|
||
func (s Server) projectOwnerIDForAccess(w http.ResponseWriter, r *http.Request, projectID int64) (int64, bool) {
|
||
owner, err := s.App.ProjectOwner(r.Context(), projectID)
|
||
if errors.Is(err, ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return 0, false
|
||
}
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return 0, false
|
||
}
|
||
if owner.PortalUserID == nil || !canManagePortalUser(r, *owner.PortalUserID) {
|
||
writeError(w, http.StatusNotFound, "project not found")
|
||
return 0, false
|
||
}
|
||
return owner.ID, true
|
||
}
|
||
|
||
func (s Server) listingOwnerIDForAccess(w http.ResponseWriter, r *http.Request, listingID int64) (int64, bool) {
|
||
var projectID int64
|
||
err := s.App.DB.QueryRowContext(r.Context(), `
|
||
SELECT project_id FROM competitor_listings WHERE id = ?`, listingID).Scan(&projectID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "listing not found")
|
||
return 0, false
|
||
}
|
||
return s.projectOwnerIDForAccess(w, r, projectID)
|
||
}
|
||
|
||
func portalUserID(r *http.Request) string {
|
||
return strings.TrimSpace(r.Header.Get("X-User-Id"))
|
||
}
|
||
|
||
func isAdmin(r *http.Request) bool {
|
||
return commonmw.HeaderBool(r, "X-User-Is-Admin")
|
||
}
|
||
|
||
func canViewTeam(r *http.Request) bool {
|
||
return isAdmin(r) || commonmw.HeaderBool(r, "X-User-Is-Department-Head")
|
||
}
|
||
|
||
func canManagePortalUser(r *http.Request, targetPortalID string) bool {
|
||
targetPortalID = strings.TrimSpace(targetPortalID)
|
||
if targetPortalID == "" {
|
||
return false
|
||
}
|
||
if targetPortalID == portalUserID(r) || isAdmin(r) {
|
||
return true
|
||
}
|
||
for _, id := range subordinatePortalIDs(r) {
|
||
if id == targetPortalID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func subordinatePortalIDs(r *http.Request) []string {
|
||
return commonmw.HeaderCSV(r, "X-User-Subordinates")
|
||
}
|
||
|
||
func ownerPortalIDFromQuery(r *http.Request) *string {
|
||
value := strings.TrimSpace(r.URL.Query().Get("owner_portal_user_id"))
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
func nullablePlain(value string) *string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
func pathID(w http.ResponseWriter, value string) (int64, bool) {
|
||
if strings.Contains(value, "/") {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return 0, false
|
||
}
|
||
id, err := strconv.ParseInt(value, 10, 64)
|
||
if err != nil || id <= 0 {
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
return 0, false
|
||
}
|
||
return id, true
|
||
}
|
||
|
||
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||
defer r.Body.Close()
|
||
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid json")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(value)
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, detail string) {
|
||
writeJSON(w, status, map[string]string{"detail": detail})
|
||
}
|