Files
monitoring-pf/internal/pf/http.go
Grendgi 703f544cdf
All checks were successful
CI / hygiene (push) Successful in 3s
Build and Deploy / build-and-deploy (push) Successful in 36s
CI / go (push) Successful in 23s
CI / python (push) Successful in 1s
Allow managers to view subordinate PF projects
2026-06-16 09:34:17 +03:00

598 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pf
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
)
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 r.Header.Get("X-User-Is-Admin") == "1"
}
func canViewTeam(r *http.Request) bool {
return isAdmin(r) || r.Header.Get("X-User-Is-Department-Head") == "1"
}
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 {
raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates"))
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
id := strings.TrimSpace(part)
if id != "" {
out = append(out, id)
}
}
return out
}
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})
}