package handlers import ( "context" "fmt" "log/slog" "net/http" "sort" "strconv" "strings" "time" "veola/internal/apify" "veola/internal/models" "veola/internal/scheduler" "veola/templates" ) func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { cat := r.URL.Query().Get("category") all, err := a.Store.ListItems(r.Context()) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } var items []models.Item for _, it := range all { if cat == "" || it.Category == cat { items = append(items, it) } } cats, _ := a.Store.ListCategories(r.Context()) // Bulk-load recent price history so each row can render a sparkline // without N+1 queries. 20 points is enough for a meaningful trend line // at 80px wide and stays cheap on the largest realistic watchlists. ids := make([]int64, 0, len(items)) for _, it := range items { ids = append(ids, it.ID) } history, _ := a.Store.LoadRecentPriceHistory(r.Context(), ids, 20) render(w, r, templates.Items(templates.ItemsData{ Page: a.page(r, "Items", "items"), Items: items, Categories: cats, SelectedCategory: cat, PriceHistory: history, })) } func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) { cats, _ := a.Store.ListCategories(r.Context()) render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Add Item", "items"), IsEdit: false, Categories: cats, Item: models.Item{ NtfyPriority: "default", PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes, Marketplaces: []string{"ebay.com"}, ListingType: "all", }, })) } func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") it, err := a.Store.GetItem(r.Context(), id) if err != nil || it == nil { http.NotFound(w, r) return } cats, _ := a.Store.ListCategories(r.Context()) render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Edit "+it.Name, "items"), IsEdit: true, Item: *it, Categories: cats, })) } // parseItemForm pulls form fields into a models.Item plus a list of validation // errors. Used by preview, create, and update. func parseItemForm(r *http.Request) (models.Item, []string) { var it models.Item var errs []string if err := r.ParseForm(); err != nil { return it, []string{"could not parse form"} } it.Name = strings.TrimSpace(r.PostFormValue("name")) it.SearchQuery = strings.Join(models.SplitList(r.PostFormValue("search_query"), 10), "\n") it.ExcludeKeywords = strings.Join(models.SplitList(r.PostFormValue("exclude_keywords"), 20), "\n") it.URL = strings.TrimSpace(r.PostFormValue("url")) if newCat := strings.TrimSpace(r.PostFormValue("category_new")); newCat != "" { it.Category = newCat } else { it.Category = strings.TrimSpace(r.PostFormValue("category")) } it.NtfyTopic = strings.TrimSpace(r.PostFormValue("ntfy_topic")) it.NtfyPriority = strings.TrimSpace(r.PostFormValue("ntfy_priority")) if it.NtfyPriority == "" { it.NtfyPriority = "default" } it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom")) it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type")) it.Condition = strings.TrimSpace(r.PostFormValue("condition")) it.Region = strings.ToUpper(strings.TrimSpace(r.PostFormValue("region"))) it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active")) it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold")) it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare")) it.IncludeOutOfStock = r.PostFormValue("include_out_of_stock") == "1" it.UsePriceComparison = r.PostFormValue("use_price_comparison") == "1" it.Active = true if tp := strings.TrimSpace(r.PostFormValue("target_price")); tp != "" { if v, err := strconv.ParseFloat(tp, 64); err == nil && v >= 0 { it.TargetPrice = &v } } if mp := strings.TrimSpace(r.PostFormValue("min_price")); mp != "" { if v, err := strconv.ParseFloat(mp, 64); err == nil && v >= 0 { it.MinPrice = &v } } if pi := strings.TrimSpace(r.PostFormValue("poll_interval_minutes")); pi != "" { if v, err := strconv.Atoi(pi); err == nil && v > 0 { it.PollIntervalMinutes = v } } if it.PollIntervalMinutes == 0 { it.PollIntervalMinutes = 60 } if it.Name == "" { errs = append(errs, "name is required") } if it.SearchQuery == "" && it.URL == "" { errs = append(errs, "either search query or product URL is required") } if it.NtfyTopic == "" { // Default to a slug of the name. it.NtfyTopic = slugify(it.Name) } return it, errs } // collectMarketplaces dedupes the checkbox values + custom CSV input into an // ordered slice. Checkbox order first, then custom entries in the order the // user typed them. func collectMarketplaces(checked []string, custom string) []string { seen := map[string]bool{} var out []string add := func(v string) { v = strings.TrimSpace(v) if v == "" || seen[v] { return } seen[v] = true out = append(out, v) } for _, v := range checked { add(v) } for _, v := range strings.Split(custom, ",") { add(v) } return out } func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r == ' ', r == '_', r == '-': b.WriteRune('-') } } out := b.String() if out == "" { return "veola-item" } return out } func (a *App) PostPreview(w http.ResponseWriter, r *http.Request) { it, errs := parseItemForm(r) if len(errs) > 0 { render(w, r, templates.ItemPreview(templates.PreviewData{ CSRFToken: a.Auth.CSRFToken(r.Context()), Form: formValuesFromItem(it, r), Error: strings.Join(errs, "; "), })) return } results, source, cached, err := a.runPreview(r.Context(), it) if err != nil { render(w, r, templates.ItemPreview(templates.PreviewData{ CSRFToken: a.Auth.CSRFToken(r.Context()), Form: formValuesFromItem(it, r), Error: err.Error(), })) return } results = scheduler.FilterResults(results, a.Cfg.Scheduler.MatchConfidenceThreshold, it.IncludeOutOfStock) results = scheduler.ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList()) if len(results) == 0 { render(w, r, templates.ItemPreview(templates.PreviewData{ CSRFToken: a.Auth.CSRFToken(r.Context()), Form: formValuesFromItem(it, r), Empty: true, })) return } bestIdx := scheduler.PickBest(results) minP, maxP := results[0].Price, results[0].Price stores := map[string]struct{}{} cur := results[0].Currency for _, r := range results { if r.Price < minP { minP = r.Price } if r.Price > maxP { maxP = r.Price } stores[r.Store] = struct{}{} } render(w, r, templates.ItemPreview(templates.PreviewData{ CSRFToken: a.Auth.CSRFToken(r.Context()), Form: formValuesFromItem(it, r), Results: results, BestIndex: bestIdx, MinPrice: minP, MaxPrice: maxP, StoreCount: len(stores), Cached: cached, Currency: cur, })) _ = source } func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedResult, string, bool, error) { plans := a.Scheduler.BuildPreviewInputs(it) if len(plans) == 0 { return nil, "", false, fmt.Errorf("no actor configured for this item") } previewMarket := "" if len(it.Marketplaces) > 0 { previewMarket = it.Marketplaces[0] } queries := it.SearchQueries() sortedQ := make([]string, len(queries)) copy(sortedQ, queries) sort.Strings(sortedQ) actorIDs := make([]string, 0, len(plans)) for _, p := range plans { actorIDs = append(actorIDs, p.ActorID()) } sort.Strings(actorIDs) key := previewKey{ Queries: strings.Join(sortedQ, "\n"), URL: it.URL, Marketplace: previewMarket, ListingType: it.ListingType, ActorIDs: strings.Join(actorIDs, ","), Condition: it.Condition, Region: it.Region, MaxResults: 30, } if cached, src, ok := a.Preview.Get(key); ok { return cached, src, true, nil } var merged []apify.UnifiedResult primarySource := "" for _, p := range plans { decoded, err := a.Scheduler.ExecutePlan(ctx, p) if err != nil { slog.Warn("preview plan failed", "provider", p.Provider(), "marketplace", p.Marketplace(), "query", p.Query(), "err", err, ) continue } merged = append(merged, decoded...) if primarySource == "" { primarySource = p.Source() } } merged = scheduler.DedupByURL(merged) a.Preview.Put(key, merged, primarySource) return merged, primarySource, false, nil } func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues { tp := "" if it.TargetPrice != nil { tp = fmt.Sprintf("%.2f", *it.TargetPrice) } mp := "" if it.MinPrice != nil { mp = fmt.Sprintf("%.2f", *it.MinPrice) } return templates.FormValues{ Name: it.Name, SearchQuery: it.SearchQuery, URL: it.URL, Category: it.Category, TargetPrice: tp, MinPrice: mp, ExcludeKeywords: it.ExcludeKeywords, NtfyTopic: it.NtfyTopic, NtfyPriority: it.NtfyPriority, PollIntervalMinutes: fmt.Sprintf("%d", it.PollIntervalMinutes), IncludeOutOfStock: it.IncludeOutOfStock, Marketplaces: it.Marketplaces, ListingType: it.ListingType, Condition: it.Condition, Region: it.Region, ActorActive: it.ActorActive, ActorSold: it.ActorSold, ActorPriceCompare: it.ActorPriceCompare, UsePriceComparison: it.UsePriceComparison, } } func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) { it, errs := parseItemForm(r) if len(errs) > 0 { http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest) return } id, err := a.Store.CreateItem(r.Context(), &it) if err != nil { a.serverError(w, r, err) return } it.ID = id a.Scheduler.SyncItem(it) go func() { bg := context.Background() fresh, err := a.Store.GetItem(bg, id) if err != nil || fresh == nil { return } a.Scheduler.SeedSoldHistory(bg, *fresh) a.Scheduler.RunPoll(bg, *fresh) }() http.Redirect(w, r, fmt.Sprintf("/items/%d/results", id), http.StatusSeeOther) } func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") existing, err := a.Store.GetItem(r.Context(), id) if err != nil || existing == nil { http.NotFound(w, r) return } updated, errs := parseItemForm(r) if len(errs) > 0 { cats, _ := a.Store.ListCategories(r.Context()) updated.ID = id render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Edit "+updated.Name, "items"), IsEdit: true, Item: updated, Errors: errs, Categories: cats, })) return } updated.ID = id updated.Active = existing.Active if err := a.Store.UpdateItem(r.Context(), &updated); err != nil { a.serverError(w, r, err) return } a.Scheduler.SyncItem(updated) http.Redirect(w, r, "/items", http.StatusSeeOther) } func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") it, err := a.Store.GetItem(r.Context(), id) if err != nil || it == nil { http.NotFound(w, r) return } it.Active = !it.Active if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil { a.serverError(w, r, err) return } a.Scheduler.SyncItem(*it) hist, _ := a.Store.LoadRecentPriceHistory(r.Context(), []int64{id}, 20) render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()), hist[id])) } func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") if err := a.Store.DeleteItem(r.Context(), id); err != nil { a.serverError(w, r, err) return } a.Scheduler.RemoveItem(id) render(w, r, templates.EmptyRow()) } func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") it, err := a.Store.GetItem(r.Context(), id) if err != nil || it == nil { http.NotFound(w, r) return } // Run synchronously so the response reflects the finished poll. Bounded so // a slow Apify actor run can't tie the request up indefinitely (eBay // Browse API polls finish in seconds, well within this). Detached from the // request context so a client disconnect mid-run doesn't abort DB writes. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() a.Scheduler.RunPoll(ctx, *it) // A partial swap (single row or just the results table) leaves the rest // of the page — best-price card, price chart, "last polled" time, badge — // looking stale, so the run reads as a no-op. Tell htmx to do a full // reload so every derived view picks up the post-poll state. if r.Header.Get("HX-Request") != "" { w.Header().Set("HX-Refresh", "true") w.WriteHeader(http.StatusNoContent) return } // Non-htmx fallback: redirect back to the originating page. target := "/items" if r.PostFormValue("from") == "results" { target = fmt.Sprintf("/items/%d/results", id) } http.Redirect(w, r, target, http.StatusSeeOther) } func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") it, err := a.Store.GetItem(r.Context(), id) if err != nil || it == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, "%s", htmlEscape(it.LastPollError)) } func htmlEscape(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """) return r.Replace(s) }