Files
veola/internal/handlers/items.go
prosolis d87536c879 Fix bugs found in local testing
- Dashboard auto-refresh rendered the full layout into its own
  refresh container, producing a duplicate sidebar every 60s; it now
  renders only the body partial.
- 'Run Now' runs synchronously with a bounded timeout and returns
  refreshed results plus success/error feedback, instead of
  firing-and-forgetting with no signal.
- Price-history chart data moved from a <script> block to a data-
  attribute: templ does not interpolate expressions inside <script>
  element content, so the JSON was emitted literally.
- The htmx indicator spinner was permanently visible due to CSS
  source order; the indicator rules now follow .v-spinner.

Also refreshes README for this session's changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:11:07 -07:00

455 lines
13 KiB
Go

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())
render(w, r, templates.Items(templates.ItemsData{
Page: a.page(r, "Items", "items"),
Items: items,
Categories: cats,
SelectedCategory: cat,
}))
}
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.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, ","),
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,
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 {
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
a.Scheduler.SyncItem(*it)
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
}
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id")
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
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)
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch
// so the rendered partial shows the post-poll state.
fresh, err := a.Store.GetItem(r.Context(), id)
if err != nil || fresh == nil {
http.Error(w, "could not reload item after run", http.StatusInternalServerError)
return
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
if r.PostFormValue("from") == "results" {
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if fresh.LastPollError != "" {
d.RunError = "Run finished with errors: " + fresh.LastPollError
} else {
d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results))
}
render(w, r, templates.ItemResultsTable(d))
return
}
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
}
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, "<span>%s</span>", htmlEscape(it.LastPollError))
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
return r.Replace(s)
}