- Bulk-load recent price points per item and render a sparkline in the items list (new LoadRecentPriceHistory query avoids N+1). - Add retro.css visual layer and refreshed login/items/layout styling. - Swap the logo from webp to avif. - Pin htmx/Chart.js/Tailwind/templ versions in the Makefile with vendor / tools / update-deps targets; README documents the dependency-bump flow and the hardened systemd deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
464 lines
13 KiB
Go
464 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())
|
|
|
|
// 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, "<span>%s</span>", htmlEscape(it.LastPollError))
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """)
|
|
return r.Replace(s)
|
|
}
|