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