From 974090df4faaf3ea2fb06c7c403e381f617a34d3 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Thu, 11 Jun 2026 16:56:23 +0300 Subject: [PATCH] Allow PF managers to manage subordinate projects --- internal/pf/http.go | 121 ++++++++++++++++++++++++++++++++++++------- internal/pf/store.go | 68 ++++++++++++++++++++---- 2 files changed, 160 insertions(+), 29 deletions(-) diff --git a/internal/pf/http.go b/internal/pf/http.go index 2747240..e23fe05 100644 --- a/internal/pf/http.go +++ b/internal/pf/http.go @@ -134,7 +134,15 @@ func (s Server) employees(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - items, err := s.App.ListEmployees(r.Context(), isAdmin(r), emp) + 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 @@ -202,12 +210,12 @@ func (s Server) employeeItem(w http.ResponseWriter, r *http.Request, path string } func (s Server) projects(w http.ResponseWriter, r *http.Request) { - emp, ok := s.requireEmployee(w, r) - if !ok { - return - } switch r.Method { case http.MethodGet: + 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()) @@ -219,7 +227,11 @@ func (s Server) projects(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &payload) { return } - project, err := s.App.CreateProject(r.Context(), emp.ID, payload) + 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 @@ -231,10 +243,6 @@ func (s Server) projects(w http.ResponseWriter, r *http.Request) { } func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) { - emp, ok := s.requireEmployee(w, r) - if !ok { - return - } rest := strings.TrimPrefix(path, "/api/v1/projects/") parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 0 { @@ -245,13 +253,17 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) if !ok { return } + ownerID, ok := s.projectOwnerIDForAccess(w, r, projectID) + if !ok { + return + } if len(parts) == 1 { - s.projectCRUD(w, r, emp.ID, projectID) + 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(), emp.ID, projectID, false); err != nil { + if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil { writeError(w, http.StatusNotFound, "project not found") return } @@ -262,7 +274,7 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) } 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(), emp.ID, projectID, false); err != nil { + if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil { writeError(w, http.StatusNotFound, "project not found") return } @@ -273,9 +285,9 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) } writeJSON(w, http.StatusOK, out) case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost: - s.addListing(w, r, emp.ID, projectID) + s.addListing(w, r, ownerID, projectID) case len(parts) == 3 && parts[1] == "listings" && parts[2] == "bulk" && r.Method == http.MethodPost: - s.addListings(w, r, emp.ID, projectID) + s.addListings(w, r, ownerID, projectID) default: writeError(w, http.StatusNotFound, "not found") } @@ -356,10 +368,6 @@ func (s Server) addListings(w http.ResponseWriter, r *http.Request, ownerID, pro } func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) { - emp, ok := s.requireEmployee(w, r) - if !ok { - return - } id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/")) if !ok { return @@ -368,7 +376,11 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } - if err := s.App.DeleteListing(r.Context(), emp.ID, id); err != nil { + 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 } @@ -388,6 +400,59 @@ func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employ 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) 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")) } @@ -400,6 +465,22 @@ 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 == "" { diff --git a/internal/pf/store.go b/internal/pf/store.go index dd1aa60..5dcbc37 100644 --- a/internal/pf/store.go +++ b/internal/pf/store.go @@ -64,6 +64,27 @@ func (a *App) ListEmployees(ctx context.Context, isAdmin bool, current *Employee return scanEmployees(rows) } +func (a *App) ListEmployeesByPortalUserIDs(ctx context.Context, portalUserIDs []string) ([]Employee, error) { + ids := uniqueNonEmpty(portalUserIDs) + if len(ids) == 0 { + return []Employee{}, nil + } + args := make([]any, 0, len(ids)) + placeholders := make([]string, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + placeholders = append(placeholders, "?") + } + rows, err := a.DB.QueryContext(ctx, employeeSelect()+` + WHERE e.portal_user_id IN (`+strings.Join(placeholders, ",")+`) + ORDER BY e.name COLLATE NOCASE`, args...) + if err != nil { + return nil, err + } + defer closeRows(rows) + return scanEmployees(rows) +} + type EmployeePayload struct { Name string `json:"name"` PortalUserID *string `json:"portal_user_id"` @@ -218,16 +239,34 @@ func scanEmployees(rows *sql.Rows) ([]Employee, error) { return items, rows.Err() } +func uniqueNonEmpty(values []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + type ProjectPayload struct { - Title string `json:"title"` - DealType string `json:"deal_type"` - OurPrice *float64 `json:"our_price"` - Notes *string `json:"notes"` - DLDPermit *string `json:"dld_permit"` - Building *string `json:"building"` - Bedrooms *int64 `json:"bedrooms"` - SizeSqft *float64 `json:"size_sqft"` - OurURL *string `json:"our_url"` + Title string `json:"title"` + DealType string `json:"deal_type"` + OurPrice *float64 `json:"our_price"` + Notes *string `json:"notes"` + DLDPermit *string `json:"dld_permit"` + Building *string `json:"building"` + Bedrooms *int64 `json:"bedrooms"` + SizeSqft *float64 `json:"size_sqft"` + OurURL *string `json:"our_url"` + OwnerPortalUserID *string `json:"owner_portal_user_id"` } func (a *App) Summary(ctx context.Context, emp *Employee) (map[string]any, error) { @@ -295,6 +334,17 @@ func (a *App) ProjectByID(ctx context.Context, ownerID, projectID int64, detail return p, nil } +func (a *App) ProjectOwner(ctx context.Context, projectID int64) (*Employee, error) { + row := a.DB.QueryRowContext(ctx, employeeSelect()+` + JOIN projects p ON p.owner_id = e.id + WHERE p.id = ?`, projectID) + emp, err := scanEmployee(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return emp, err +} + func (a *App) CreateProject(ctx context.Context, ownerID int64, p ProjectPayload) (*Project, error) { title := cleanString(p.Title) if title == "" {