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())
|
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 == "" {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user