Allow PF managers to manage subordinate projects
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user