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}) }