diff --git a/app/worker.py b/app/worker.py
index 6146cb7..ac8c194 100644
--- a/app/worker.py
+++ b/app/worker.py
@@ -15,8 +15,6 @@ from app.models import Project
from app.services.monitor import (
BAYUT_ENABLED,
add_competitor_url,
- _format_listing_added,
- notify_project_changes,
run_check_all,
run_check_for_project,
sync_permit_competitors,
@@ -66,7 +64,6 @@ def cmd_add_listing(payload: dict[str, Any]) -> None:
listing, err = add_competitor_url(db, project, url)
if err:
_fail(err)
- notify_project_changes(project, [_format_listing_added(project, listing, auto=False)])
_write({"listing_id": listing.id})
finally:
db.close()
@@ -83,7 +80,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
added = 0
skipped = 0
errors: list[str] = []
- notifications: list[str] = []
seen: set[str] = set()
for raw in urls:
url = str(raw or "").strip()
@@ -97,9 +93,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
errors.append(err)
else:
added += 1
- notifications.append(_format_listing_added(project, listing, auto=False))
- if notifications:
- notify_project_changes(project, notifications)
_write({"added": added, "skipped": skipped, "errors": errors})
finally:
db.close()
diff --git a/internal/pf/db.go b/internal/pf/db.go
index 4dc1b9e..33a3ab2 100644
--- a/internal/pf/db.go
+++ b/internal/pf/db.go
@@ -76,6 +76,22 @@ type PricePoint struct {
RecordedAt *string `json:"recorded_at"`
}
+type TeamOverviewRow struct {
+ EmployeeID int64 `json:"employee_id"`
+ EmployeeName string `json:"employee_name"`
+ PortalUserID *string `json:"portal_user_id"`
+ TelegramLinked bool `json:"telegram_linked"`
+ ProjectID *int64 `json:"project_id"`
+ ProjectTitle *string `json:"project_title"`
+ DealType *string `json:"deal_type"`
+ DLDPermit *string `json:"dld_permit"`
+ LastCheckedAt *string `json:"last_checked_at"`
+ ListingsTotal int64 `json:"listings_total"`
+ ListingsActive int64 `json:"listings_active"`
+ ListingsRemoved int64 `json:"listings_removed"`
+ MinCompetitorPrice *float64 `json:"min_competitor_price"`
+}
+
func OpenApp(ctx context.Context, cfg Config) (*App, error) {
db, err := sql.Open("sqlite", sqliteDSN(cfg.DatabaseURL))
if err != nil {
diff --git a/internal/pf/http.go b/internal/pf/http.go
index c02c370..2747240 100644
--- a/internal/pf/http.go
+++ b/internal/pf/http.go
@@ -33,6 +33,8 @@ func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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/"):
@@ -86,6 +88,7 @@ func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
"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,
@@ -110,6 +113,19 @@ func (s Server) summary(w http.ResponseWriter, r *http.Request) {
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:
@@ -352,45 +368,13 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
- deleted, err := s.App.DeleteListing(r.Context(), emp.ID, id)
- if err != nil {
+ if err := s.App.DeleteListing(r.Context(), emp.ID, id); err != nil {
writeError(w, http.StatusNotFound, "listing not found")
return
}
- if deleted.OwnerChatID != nil && *deleted.OwnerChatID != "" {
- _ = s.App.TG.SendMessage(r.Context(), *deleted.OwnerChatID, formatDeletedListingMessage(deleted))
- }
w.WriteHeader(http.StatusNoContent)
}
-func formatDeletedListingMessage(deleted *DeletedListing) string {
- listing := deleted.Listing
- title := "без названия"
- if listing.Title != nil && *listing.Title != "" {
- title = *listing.Title
- }
- price := "—"
- if listing.CurrentPrice != nil {
- currency := "AED"
- if listing.Currency != nil && *listing.Currency != "" {
- currency = *listing.Currency
- }
- price = strconv.FormatFloat(*listing.CurrentPrice, 'f', 0, 64) + " " + currency
- }
- permit := "—"
- if listing.PermitNumber != nil && *listing.PermitNumber != "" {
- permit = *listing.PermitNumber
- }
- return "🏠 " + deleted.ProjectTitle + "\n" +
- "Тип: " + deleted.ProjectDeal + " · Изменений: 1\n" +
- "——————————\n" +
- "🗑️ Удален конкурент — " + listing.Source + "\n" +
- title + "\n" +
- "Последняя цена: " + price + "\n" +
- "Permit: " + permit + "\n" +
- listing.URL
-}
-
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) {
@@ -412,6 +396,26 @@ func isAdmin(r *http.Request) bool {
return r.Header.Get("X-User-Is-Admin") == "1"
}
+func canViewTeam(r *http.Request) bool {
+ return isAdmin(r) || r.Header.Get("X-User-Is-Department-Head") == "1"
+}
+
+func subordinatePortalIDs(r *http.Request) []string {
+ raw := strings.TrimSpace(r.Header.Get("X-User-Subordinates"))
+ if raw == "" {
+ return []string{}
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ id := strings.TrimSpace(part)
+ if id != "" {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
func nullablePlain(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
diff --git a/internal/pf/store.go b/internal/pf/store.go
index 7117952..9908c42 100644
--- a/internal/pf/store.go
+++ b/internal/pf/store.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "strings"
)
var ErrNotFound = errors.New("not found")
@@ -386,53 +387,34 @@ func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error
return tx.Commit()
}
-type DeletedListing struct {
- Listing *Listing
- ProjectTitle string
- ProjectDeal string
- OwnerChatID *string
-}
-
-func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) (*DeletedListing, error) {
- row := a.DB.QueryRowContext(ctx, listingSelect()+`
- JOIN projects p ON p.id = l.project_id
- WHERE l.id = ? AND p.owner_id = ?`, listingID, ownerID)
- listing, err := scanListing(row, false)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, ErrNotFound
- }
- if err != nil {
- return nil, err
- }
- deleted := &DeletedListing{Listing: listing}
- var deal string
- var chat sql.NullString
- if err := a.DB.QueryRowContext(ctx, `
- SELECT p.title, p.deal_type, e.tg_chat_id
- FROM projects p
- JOIN employees e ON e.id = p.owner_id
- WHERE p.id = ? AND p.owner_id = ?`, listing.ProjectID, ownerID).
- Scan(&deleted.ProjectTitle, &deal, &chat); err != nil {
- return nil, err
- }
- deleted.ProjectDeal = enumDealOut(deal)
- deleted.OwnerChatID = nullableString(chat)
-
+func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) error {
tx, err := a.DB.BeginTx(ctx, nil)
if err != nil {
- return nil, err
+ return err
}
defer tx.Rollback()
- if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, listing.ID); err != nil {
- return nil, err
+ if _, err := tx.ExecContext(ctx, `
+ DELETE FROM price_history
+ WHERE listing_id IN (
+ SELECT l.id
+ FROM competitor_listings l
+ JOIN projects p ON p.id = l.project_id
+ WHERE l.id = ? AND p.owner_id = ?
+ )`, listingID, ownerID); err != nil {
+ return err
}
- if _, err := tx.ExecContext(ctx, `DELETE FROM competitor_listings WHERE id = ?`, listing.ID); err != nil {
- return nil, err
+ res, err := tx.ExecContext(ctx, `
+ DELETE FROM competitor_listings
+ WHERE id = ?
+ AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)`, listingID, ownerID)
+ if err != nil {
+ return err
}
- if err := tx.Commit(); err != nil {
- return nil, err
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return ErrNotFound
}
- return deleted, nil
+ return tx.Commit()
}
func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) {
@@ -542,6 +524,87 @@ func (a *App) ListingsForProject(ctx context.Context, projectID int64, withHisto
return items, nil
}
+func (a *App) TeamOverview(ctx context.Context, portalUserIDs []string, all bool) ([]TeamOverviewRow, error) {
+ args := []any{}
+ where := ""
+ if !all {
+ if len(portalUserIDs) == 0 {
+ return []TeamOverviewRow{}, nil
+ }
+ placeholders := make([]string, 0, len(portalUserIDs))
+ for _, id := range portalUserIDs {
+ placeholders = append(placeholders, "?")
+ args = append(args, id)
+ }
+ where = "WHERE e.portal_user_id IN (" + strings.Join(placeholders, ",") + ")"
+ }
+ rows, err := a.DB.QueryContext(ctx, `
+ SELECT e.id,
+ e.name,
+ e.portal_user_id,
+ e.tg_chat_id,
+ p.id,
+ p.title,
+ p.deal_type,
+ p.dld_permit,
+ p.last_checked_at,
+ count(l.id),
+ sum(CASE WHEN l.status IN ('ACTIVE','active') THEN 1 ELSE 0 END),
+ sum(CASE WHEN l.status IN ('REMOVED','removed') THEN 1 ELSE 0 END),
+ min(CASE WHEN l.status IN ('ACTIVE','active') THEN l.current_price ELSE NULL END)
+ FROM employees e
+ LEFT JOIN projects p ON p.owner_id = e.id
+ LEFT JOIN competitor_listings l ON l.project_id = p.id
+ `+where+`
+ GROUP BY e.id, e.name, e.portal_user_id, e.tg_chat_id,
+ p.id, p.title, p.deal_type, p.dld_permit, p.last_checked_at, p.created_at
+ ORDER BY e.name COLLATE NOCASE, p.created_at DESC`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []TeamOverviewRow{}
+ for rows.Next() {
+ var item TeamOverviewRow
+ var portalID, chatID, title, deal, permit, checked sql.NullString
+ var projectID, total, active, removed sql.NullInt64
+ var minPrice sql.NullFloat64
+ if err := rows.Scan(
+ &item.EmployeeID,
+ &item.EmployeeName,
+ &portalID,
+ &chatID,
+ &projectID,
+ &title,
+ &deal,
+ &permit,
+ &checked,
+ &total,
+ &active,
+ &removed,
+ &minPrice,
+ ); err != nil {
+ return nil, err
+ }
+ item.PortalUserID = nullableString(portalID)
+ item.TelegramLinked = chatID.Valid && chatID.String != ""
+ item.ProjectID = nullableInt(projectID)
+ item.ProjectTitle = nullableString(title)
+ if deal.Valid {
+ value := enumDealOut(deal.String)
+ item.DealType = &value
+ }
+ item.DLDPermit = nullableString(permit)
+ item.LastCheckedAt = timeOut(checked)
+ item.ListingsTotal = nullIntValue(total)
+ item.ListingsActive = nullIntValue(active)
+ item.ListingsRemoved = nullIntValue(removed)
+ item.MinCompetitorPrice = nullableFloat(minPrice)
+ items = append(items, item)
+ }
+ return items, rows.Err()
+}
+
func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint, error) {
rows, err := a.DB.QueryContext(ctx, `
SELECT id, price, recorded_at