Allow PF managers to manage subordinate projects
All checks were successful
CI / go (push) Successful in 22s
CI / python (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 34s

This commit is contained in:
Grendgi
2026-06-11 16:56:23 +03:00
parent c763ff423d
commit 974090df4f
2 changed files with 160 additions and 29 deletions

View File

@@ -134,7 +134,15 @@ func (s Server) employees(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s Server) projects(w http.ResponseWriter, r *http.Request) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
items, err := s.App.ListProjects(r.Context(), emp.ID) items, err := s.App.ListProjects(r.Context(), emp.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) 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) { if !decodeJSON(w, r, &payload) {
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return 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) { 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/") rest := strings.TrimPrefix(path, "/api/v1/projects/")
parts := strings.Split(strings.Trim(rest, "/"), "/") parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 0 { if len(parts) == 0 {
@@ -245,13 +253,17 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string)
if !ok { if !ok {
return return
} }
ownerID, ok := s.projectOwnerIDForAccess(w, r, projectID)
if !ok {
return
}
if len(parts) == 1 { if len(parts) == 1 {
s.projectCRUD(w, r, emp.ID, projectID) s.projectCRUD(w, r, ownerID, projectID)
return return
} }
switch { switch {
case len(parts) == 2 && parts[1] == "check" && r.Method == http.MethodPost: 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") writeError(w, http.StatusNotFound, "project not found")
return 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}) writeJSON(w, http.StatusOK, map[string]int{"changes": changes})
case len(parts) == 2 && parts[1] == "suggest" && r.Method == http.MethodGet: 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") writeError(w, http.StatusNotFound, "project not found")
return return
} }
@@ -273,9 +285,9 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string)
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost: 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: 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: default:
writeError(w, http.StatusNotFound, "not found") 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) { 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/")) id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/"))
if !ok { if !ok {
return return
@@ -368,7 +376,11 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string)
writeError(w, http.StatusMethodNotAllowed, "method not allowed") writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return 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") writeError(w, http.StatusNotFound, "listing not found")
return return
} }
@@ -388,6 +400,59 @@ func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employ
return emp, true 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 { func portalUserID(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-User-Id")) 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" 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 { func subordinatePortalIDs(r *http.Request) []string {
raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates")) raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates"))
if raw == "" { if raw == "" {

View File

@@ -64,6 +64,27 @@ func (a *App) ListEmployees(ctx context.Context, isAdmin bool, current *Employee
return scanEmployees(rows) 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 { type EmployeePayload struct {
Name string `json:"name"` Name string `json:"name"`
PortalUserID *string `json:"portal_user_id"` PortalUserID *string `json:"portal_user_id"`
@@ -218,16 +239,34 @@ func scanEmployees(rows *sql.Rows) ([]Employee, error) {
return items, rows.Err() 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 { type ProjectPayload struct {
Title string `json:"title"` Title string `json:"title"`
DealType string `json:"deal_type"` DealType string `json:"deal_type"`
OurPrice *float64 `json:"our_price"` OurPrice *float64 `json:"our_price"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
DLDPermit *string `json:"dld_permit"` DLDPermit *string `json:"dld_permit"`
Building *string `json:"building"` Building *string `json:"building"`
Bedrooms *int64 `json:"bedrooms"` Bedrooms *int64 `json:"bedrooms"`
SizeSqft *float64 `json:"size_sqft"` SizeSqft *float64 `json:"size_sqft"`
OurURL *string `json:"our_url"` 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) { 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 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) { func (a *App) CreateProject(ctx context.Context, ownerID int64, p ProjectPayload) (*Project, error) {
title := cleanString(p.Title) title := cleanString(p.Title)
if title == "" { if title == "" {