Auction end times, visual flair, and pre-launch cleanup

Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 17:47:09 -07:00
parent d87536c879
commit edb732ee1f
39 changed files with 2264 additions and 947 deletions

View File

@@ -2,10 +2,70 @@ package apify
import (
"encoding/json"
"log/slog"
"strconv"
"strings"
"time"
)
// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times
// in when the value lacks an explicit offset. Falls back to UTC if the
// embedded tzdata lookup fails (main.go imports time/tzdata so in practice
// this always resolves).
var jstLocation = func() *time.Location {
loc, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
return time.UTC
}
return loc
}()
// yahooEndingDateLayouts is the ordered list of layouts attempted when
// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no
// offset in the string) — see parseYahooEndingDate for the routing.
var yahooEndingDateLayouts = []struct {
layout string
jst bool // true: parse as JST; false: parse with offset/zone from string
}{
{time.RFC3339, false},
{"2006-01-02T15:04:05", true},
{"2006-01-02 15:04:05", true},
{"2006/01/02 15:04:05", true},
{"2006/01/02 15:04", true},
{"2006-01-02 15:04", true},
}
// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date
// field. The actor's output format has shifted over time and isn't documented;
// rather than wedging on one layout, try the known shapes in order and treat
// anything without an explicit zone as JST (Yahoo Auctions runs in Japan).
// Returns nil + logs a warning when nothing parses, so operators see the raw
// value and can extend this list.
func parseYahooEndingDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
for _, l := range yahooEndingDateLayouts {
var (
t time.Time
err error
)
if l.jst {
t, err = time.ParseInLocation(l.layout, s, jstLocation)
} else {
t, err = time.Parse(l.layout, s)
}
if err == nil {
slog.Debug("yahoo ending_date parsed",
"raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339))
return &t
}
}
slog.Warn("yahoo ending_date: no layout matched", "raw", s)
return nil
}
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
// The actor accepts keyword searches and standard filters; it targets
// ebay.com only (no per-marketplace routing in the actor itself), so
@@ -145,6 +205,10 @@ type UnifiedResult struct {
// MatchedQuery records which alias from the item's query list produced
// this row. Empty for URL-only items or rows from non-search sources.
MatchedQuery string
// EndsAt is the auction end time, if known. Auction-format eBay listings
// and Yahoo Auctions JP listings populate this; fixed-price listings
// and most Apify scraper outputs leave it nil.
EndsAt *time.Time
}
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using
@@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
}
case SourceYahooJP:
for _, raw := range items {
slog.Debug("yahoo raw item", "json", string(raw))
var r YahooAuctionsJPResult
if err := json.Unmarshal(raw, &r); err != nil {
slog.Debug("yahoo decode failed", "err", err, "json", string(raw))
continue
}
img := ""
@@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
Store: "yahoo-auctions-jp (via zenmarket)",
ImageURL: img,
Source: source,
EndsAt: parseYahooEndingDate(r.EndingDate),
})
}
case SourceMercariJP:

View File

@@ -73,6 +73,19 @@ func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a
// login attempt for a non-existent user can still run a real bcrypt
// comparison, equalizing response time and closing the user-enumeration
// timing oracle.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost)
// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so
// that a missing username costs roughly the same wall-clock time as a wrong
// password. Call it on the no-such-user branch of login.
func EqualizeLoginTiming() {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x"))
}
// LogIn writes the user id into the session and rotates the token.
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
if err := m.Sessions.RenewToken(ctx); err != nil {

View File

@@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}

View File

@@ -274,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error)
INSERT INTO items (
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type,
listing_type, condition, region,
actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, best_price, best_price_store, best_price_url, best_price_image_url,
active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url,
best_price_title, last_polled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType),
nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
nullFloat(it.BestPrice), nullStr(it.BestPriceStore),
nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), nullStr(it.BestPriceStore),
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
)
@@ -310,7 +310,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
listing_type = ?,
listing_type = ?, condition = ?, region = ?,
actor_active = ?, actor_sold = ?, actor_price_compare = ?,
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
@@ -319,7 +319,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType),
nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
it.ID,
@@ -491,11 +491,12 @@ func (s *Store) ListCategories(ctx context.Context) ([]string, error) {
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
var (
bestPrice sql.NullFloat64
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
bestPrice sql.NullFloat64
bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
)
if best != nil {
bestPrice = nullFloat(best.BestPrice)
bestCurrency = nullStr(best.BestPriceCurrency)
bestStore = nullStr(best.BestPriceStore)
bestURL = nullStr(s.enc(best.BestPriceURL))
bestImage = nullStr(s.enc(best.BestPriceImageURL))
@@ -506,20 +507,20 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
}
_, err := s.DB.ExecContext(ctx, `
UPDATE items SET
best_price = ?, best_price_store = ?, best_price_url = ?,
best_price = ?, best_price_currency = ?, best_price_store = ?, best_price_url = ?,
best_price_image_url = ?, best_price_title = ?,
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
WHERE id = ?
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id)
`, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id)
return err
}
const itemSelect = `
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type,
listing_type, condition, region,
actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, last_polled_at, last_poll_error, best_price, best_price_store,
active, last_polled_at, last_poll_error, best_price, best_price_currency, best_price_store,
best_price_url, best_price_image_url, best_price_title, created_at, updated_at
FROM items
`
@@ -532,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
var (
it models.Item
searchQuery, urlS, category, listingType sql.NullString
excludeKw sql.NullString
excludeKw, condition, region sql.NullString
actorA, actorS, actorP sql.NullString
ntfyTopic, lastPollErr sql.NullString
bestStore, bestURL, bestImage, bestTitle sql.NullString
bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString
targetPrice, minPrice, bestPrice sql.NullFloat64
includeOOS, usePC, active int
lastPolledAt sql.NullTime
@@ -543,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) {
if err := r.Scan(
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
&listingType,
&listingType, &condition, &region,
&actorA, &actorS, &actorP, &usePC,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore,
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
@@ -556,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) {
it.URL = urlS.String
it.Category = category.String
it.ListingType = listingType.String
it.Condition = condition.String
it.Region = region.String
it.ActorActive = actorA.String
it.ActorSold = actorS.String
it.ActorPriceCompare = actorP.String
it.NtfyTopic = ntfyTopic.String
it.LastPollError = lastPollErr.String
it.BestPriceCurrency = bestCurrency.String
it.BestPriceStore = bestStore.String
it.BestPriceURL = bestURL.String
it.BestPriceImageURL = bestImage.String
@@ -589,13 +593,14 @@ func (s *Store) decryptItem(it *models.Item) *models.Item {
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
`,
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
nullStr(s.enc(r.MatchedQuery)),
boolToInt(r.Alerted),
nullTime(r.EndsAt),
)
if err != nil {
return 0, err
@@ -624,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
return err
}
// BackfillResultEndsAt sets ends_at on an existing result row that's currently
// missing one. Used during polling: when a known auction listing reappears in
// a poll result, we still want its end time recorded even though the row
// itself isn't being re-inserted (URL dedup).
func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error {
if urlStr == "" {
return nil
}
_, err := s.DB.ExecContext(ctx,
`UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`,
endsAt, itemID, urlStr)
return err
}
type ResultsQuery struct {
ItemID int64 // 0 = all items
Limit int
Offset int
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
// ExcludeEnded drops rows whose ends_at is in the past. Fixed-price
// listings (ends_at IS NULL) are kept regardless: they don't expire.
ExcludeEnded bool
}
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
@@ -646,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
limit = 20
}
args := []any{}
where := ""
var conds []string
if q.ItemID != 0 {
where = `WHERE item_id = ?`
conds = append(conds, `item_id = ?`)
args = append(args, q.ItemID)
}
if q.ExcludeEnded {
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
args = append(args, time.Now().UTC())
}
where := ""
if len(conds) > 0 {
where = `WHERE ` + strings.Join(conds, ` AND `)
}
args = append(args, limit, q.Offset)
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
`, where, order), args...)
if err != nil {
@@ -667,8 +697,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
title, urlS, source, imageS, matchQ sql.NullString
price sql.NullFloat64
alerted int
endsAt sql.NullTime
)
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil {
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil {
return nil, err
}
r.Title = s.dec(title.String)
@@ -678,19 +709,76 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
r.MatchedQuery = s.dec(matchQ.String)
r.Price = ptrFloat(price)
r.Alerted = alerted != 0
r.EndsAt = ptrTime(endsAt)
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) {
// EndingSoon is a compact projection for the "ending soon" strip: the single
// nearest-to-end auction across the user's results, with enough context to
// render and link to it.
type EndingSoon struct {
ItemID int64
ItemName string
Title string
URL string
EndsAt time.Time
}
// NextEndingResult returns the soonest-ending result whose ends_at lies in the
// window (now, now+within]. If itemID is 0 the search spans all items; nil is
// returned when no auction falls inside the window.
func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) {
now := time.Now().UTC()
cutoff := now.Add(within)
q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name
FROM results r JOIN items i ON r.item_id = i.id
WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?`
args := []any{now, cutoff}
if itemID != 0 {
q += ` AND r.item_id = ?`
args = append(args, itemID)
}
q += ` ORDER BY r.ends_at ASC LIMIT 1`
row := s.DB.QueryRowContext(ctx, q, args...)
var (
e EndingSoon
title sql.NullString
urlS sql.NullString
endsAt time.Time
)
if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
e.Title = s.dec(title.String)
e.URL = urlS.String
e.EndsAt = endsAt
return &e, nil
}
// CountResults returns the row count matching the same filters ListResults
// applies. Pagination relies on this matching the visible list, so it must
// honor ExcludeEnded too.
func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) {
var n int
q := `SELECT COUNT(*) FROM results`
args := []any{}
var conds []string
if itemID != 0 {
q += ` WHERE item_id = ?`
conds = append(conds, `item_id = ?`)
args = append(args, itemID)
}
if excludeEnded {
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
args = append(args, time.Now().UTC())
}
if len(conds) > 0 {
q += ` WHERE ` + strings.Join(conds, ` AND `)
}
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
return n, err
}

View File

@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items (
min_price REAL,
exclude_keywords TEXT,
listing_type TEXT,
condition TEXT,
region TEXT,
actor_active TEXT,
actor_sold TEXT,
actor_price_compare TEXT,
@@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items (
last_polled_at DATETIME,
last_poll_error TEXT,
best_price REAL,
best_price_currency TEXT,
best_price_store TEXT,
best_price_url TEXT,
best_price_image_url TEXT,
@@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results (
image_url TEXT,
matched_query TEXT,
alerted INTEGER DEFAULT 0,
found_at DATETIME DEFAULT CURRENT_TIMESTAMP
found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ends_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);

View File

@@ -149,6 +149,8 @@ type browseItemSummary struct {
Seller struct {
Username string `json:"username"`
} `json:"seller"`
// itemEndDate is present only on auction-format listings.
ItemEndDate string `json:"itemEndDate"`
}
// Search runs one item_summary/search call and returns normalized listings.
@@ -174,8 +176,20 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
q := url.Values{}
q.Set("q", query)
q.Set("limit", strconv.Itoa(limit))
// The Browse API "filter" parameter takes a comma-separated list of
// filter clauses; assemble whichever ones the caller requested.
var filters []string
if f := buyingOptionsFilter(p.ListingType); f != "" {
q.Set("filter", f)
filters = append(filters, f)
}
if f := conditionIDsFilter(p.Condition); f != "" {
filters = append(filters, f)
}
if f := itemLocationFilter(p.Region); f != "" {
filters = append(filters, f)
}
if len(filters) > 0 {
q.Set("filter", strings.Join(filters, ","))
}
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
@@ -214,6 +228,12 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
if s.Seller.Username != "" {
store = "ebay (" + s.Seller.Username + ")"
}
var endsAt *time.Time
if s.ItemEndDate != "" {
if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil {
endsAt = &t
}
}
out = append(out, Listing{
Title: s.Title,
Price: price,
@@ -221,6 +241,7 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
URL: s.ItemWebURL,
Store: store,
ImageURL: img,
EndsAt: endsAt,
})
}
return out, nil

View File

@@ -43,3 +43,35 @@ func TestBuyingOptionsFilter(t *testing.T) {
}
}
}
func TestConditionIDsFilter(t *testing.T) {
cases := map[string]string{
"": "",
"anything": "",
"new": "conditionIds:{1000|1500}",
"NEW": "conditionIds:{1000|1500}",
" used ": "conditionIds:{3000}",
"refurbished": "conditionIds:{2000|2010|2020|2030|2500}",
"parts": "conditionIds:{7000}",
}
for in, want := range cases {
if got := conditionIDsFilter(in); got != want {
t.Errorf("conditionIDsFilter(%q) = %q, want %q", in, got, want)
}
}
}
func TestItemLocationFilter(t *testing.T) {
cases := map[string]string{
"": "",
" ": "",
"us": "itemLocationCountry:US",
"GB": "itemLocationCountry:GB",
" jp ": "itemLocationCountry:JP",
}
for in, want := range cases {
if got := itemLocationFilter(in); got != want {
t.Errorf("itemLocationFilter(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -1,6 +1,9 @@
package ebay
import "strings"
import (
"strings"
"time"
)
// SearchParams is the input to a single Browse API item_summary/search call.
// It is provider-specific and is carried as the opaque input payload of a
@@ -14,6 +17,14 @@ type SearchParams struct {
// ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now",
// "auction"); it is mapped to a buyingOptions filter.
ListingType string
// Condition is Veola's condition vocabulary ("new", "used",
// "refurbished", "parts"); it is mapped to a conditionIds filter. Empty
// means no condition filter.
Condition string
// Region is an ISO 3166-1 alpha-2 country code constraining item
// location (mapped to the itemLocationCountry filter). Empty means no
// location filter.
Region string
// Limit caps the number of results requested (Browse API max is 200).
Limit int
}
@@ -28,6 +39,10 @@ type Listing struct {
URL string
Store string
ImageURL string
// EndsAt is the auction end time as reported by the Browse API
// (itemEndDate). Nil for fixed-price ("Buy It Now") listings, which
// don't have one.
EndsAt *time.Time
}
// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com",
@@ -92,3 +107,34 @@ func buyingOptionsFilter(listingType string) string {
return ""
}
}
// conditionIDsFilter maps Veola's condition vocabulary to a Browse API
// "conditionIds" filter clause. Each Veola value expands to the set of eBay
// condition IDs that belong to it (e.g. "new" covers both brand-new and
// new-other). An empty or unknown value yields no filter.
func conditionIDsFilter(condition string) string {
var ids string
switch strings.ToLower(strings.TrimSpace(condition)) {
case "new":
ids = "1000|1500"
case "used":
ids = "3000"
case "refurbished":
ids = "2000|2010|2020|2030|2500"
case "parts":
ids = "7000"
default:
return ""
}
return "conditionIds:{" + ids + "}"
}
// itemLocationFilter maps an ISO 3166-1 alpha-2 country code to a Browse API
// "itemLocationCountry" filter clause. An empty value yields no filter.
func itemLocationFilter(region string) string {
r := strings.ToUpper(strings.TrimSpace(region))
if r == "" {
return ""
}
return "itemLocationCountry:" + r
}

View File

@@ -27,6 +27,11 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
u, err := a.Store.GetUserByUsername(r.Context(), username)
if err != nil || u == nil {
// Run a bcrypt comparison anyway so a missing username takes the
// same time as a wrong password (no user-enumeration oracle).
auth.EqualizeLoginTiming()
}
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
render(w, r, templates.Login(templates.LoginData{
Page: a.page(r, "Sign in", ""),

View File

@@ -12,7 +12,7 @@ import (
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
d, err := a.dashboardData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
render(w, r, templates.Dashboard(d))
@@ -21,7 +21,7 @@ func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
d, err := a.dashboardData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
@@ -36,7 +36,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
if err != nil {
return templates.DashboardData{}, err
}
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20})
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true})
if err != nil {
return templates.DashboardData{}, err
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"log/slog"
"net/http"
"os"
"strconv"
"time"
@@ -52,9 +53,19 @@ func (a *App) Routes() http.Handler {
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
fs := http.FileServer(http.Dir("./static"))
// noListFS denies directory requests, so http.FileServer can't render
// an index listing of static/ if an index.html is ever absent.
fs := http.FileServer(noListFS{http.Dir("./static")})
r.Handle("/static/*", http.StripPrefix("/static/", fs))
// Health check for reverse-proxy/uptime probes. No session, no setup
// gate, no auth — just a 200 to confirm the process is serving.
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// All other routes pass through session loading + setup gate.
r.Group(func(r chi.Router) {
r.Use(a.Auth.Sessions.LoadAndSave)
@@ -169,6 +180,34 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
}
}
// noListFS wraps an http.FileSystem and refuses to open directories, which
// stops http.FileServer from emitting an auto-generated directory listing.
type noListFS struct{ fs http.FileSystem }
func (n noListFS) Open(name string) (http.File, error) {
f, err := n.fs.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if info.IsDir() {
f.Close()
return nil, os.ErrNotExist
}
return f, nil
}
// serverError logs the underlying error and returns a generic 500 to the
// client, so internal details (DB errors, file paths) never reach the browser.
func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("handler error", "path", r.URL.Path, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {

View File

@@ -0,0 +1,115 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/config"
"veola/internal/crypto"
"veola/internal/db"
"veola/internal/models"
"veola/internal/ntfy"
"veola/internal/scheduler"
)
// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The
// scheduler, apify, and ntfy clients are wired but unused by the routes we
// hit here. The returned http.Handler is App.Routes().
func newTestApp(t *testing.T) (*App, http.Handler) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
sqlDB, err := db.Open(dbPath)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { sqlDB.Close() })
key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa"))
if err != nil {
t.Fatalf("DeriveKey: %v", err)
}
store := db.NewStore(sqlDB, key)
am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false)
if err != nil {
t.Fatalf("auth.NewManager: %v", err)
}
cfg := &config.Config{}
ap := apify.New("")
nt := ntfy.New("")
sc := scheduler.New(cfg, store, ap, nt)
app := New(cfg, store, am, ap, nt, sc)
return app, app.Routes()
}
func TestHealthz(t *testing.T) {
_, h := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "ok" {
t.Fatalf("body = %q, want %q", got, "ok")
}
}
func TestSetupGateRedirectsWhenNoUsers(t *testing.T) {
_, h := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/setup" {
t.Fatalf("Location = %q, want /setup", loc)
}
}
func TestRequireAuthRedirectsToLogin(t *testing.T) {
app, h := newTestApp(t)
hash, err := auth.HashPassword("a-long-enough-password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestLoginPageRenders(t *testing.T) {
app, h := newTestApp(t)
hash, _ := auth.HashPassword("a-long-enough-password")
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<form") {
t.Fatalf("body missing <form>")
}
}

View File

@@ -93,6 +93,8 @@ func parseItemForm(r *http.Request) (models.Item, []string) {
}
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"))
@@ -253,6 +255,8 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
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 {
@@ -304,6 +308,8 @@ func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
IncludeOutOfStock: it.IncludeOutOfStock,
Marketplaces: it.Marketplaces,
ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
ActorActive: it.ActorActive,
ActorSold: it.ActorSold,
ActorPriceCompare: it.ActorPriceCompare,
@@ -319,7 +325,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
}
id, err := a.Store.CreateItem(r.Context(), &it)
if err != nil {
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
it.ID = id
@@ -361,7 +367,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
updated.ID = id
updated.Active = existing.Active
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
a.Scheduler.SyncItem(updated)
@@ -377,7 +383,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
}
it.Active = !it.Active
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
a.Scheduler.SyncItem(*it)
@@ -387,7 +393,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
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)
a.serverError(w, r, err)
return
}
a.Scheduler.RemoveItem(id)
@@ -410,31 +416,22 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
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)
// 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
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
// Non-htmx fallback: redirect back to the originating page.
target := "/items"
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
target = fmt.Sprintf("/items/%d/results", id)
}
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {

View File

@@ -10,8 +10,13 @@ import (
// previewKey caches the *raw* apify result set (post-decode, post-merge,
// pre-filter). Filters like min_price and exclude_keywords are applied after
// the cache lookup so the operator can iterate on them without burning credits.
//
// Condition and Region are part of the key, not post-filters: they are
// server-side eBay Browse API filters that change the result set the API
// returns, so a different condition/region must miss the cache.
type previewKey struct {
Queries, URL, Marketplace, ListingType, ActorIDs string
Condition, Region string
MaxResults int
}

View File

@@ -24,7 +24,7 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
render(w, r, templates.ItemResults(d))
@@ -41,7 +41,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
page = 1
}
total, err := a.Store.CountResults(r.Context(), it.ID)
total, err := a.Store.CountResults(r.Context(), it.ID, true)
if err != nil {
return templates.ItemResultsData{}, err
}
@@ -54,10 +54,11 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
}
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
ExcludeEnded: true,
})
if err != nil {
return templates.ItemResultsData{}, err
@@ -68,6 +69,10 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
return templates.ItemResultsData{}, err
}
// 24h surface for the "ending soon" strip — beyond that, a static
// "ends in 4 days" in the per-row cell carries enough signal on its own.
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
return templates.ItemResultsData{
Page: a.page(r, it.Name, "items"),
Item: *it,
@@ -78,6 +83,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
TotalPages: totalPages,
Order: order,
HistoryChartJSON: buildChartJSON(history),
EndingSoon: endingSoon,
}, nil
}
@@ -101,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
items, err := a.Store.ListItems(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
names := make(map[int64]string, len(items))
@@ -110,11 +116,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
}
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: itemID,
Limit: 200,
ItemID: itemID,
Limit: 200,
ExcludeEnded: true,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
@@ -138,12 +145,15 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
})
}
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
Page: a.page(r, "Results", "results"),
Items: items,
Results: rows,
ItemID: itemID,
From: from,
To: to,
Page: a.page(r, "Results", "results"),
Items: items,
Results: rows,
ItemID: itemID,
From: from,
To: to,
EndingSoon: endingSoon,
}))
}

View File

@@ -26,6 +26,40 @@ var settingsKeys = []string{
"match_confidence_threshold",
}
// secretSettingsKeys are credential fields. Their values are never rendered
// back into the form, so a blank submission means "leave unchanged" rather
// than "clear" — see PostSettings.
var secretSettingsKeys = map[string]bool{
"apify_api_key": true,
"ebay_client_id": true,
"ebay_client_secret": true,
"ntfy_token": true,
}
// credentialStatus reports, per secret key, whether a value is saved in the
// settings table, inherited from config.toml, or absent — without exposing
// the secret itself.
func (a *App) credentialStatus(values map[string]string) map[string]string {
configVals := map[string]string{
"apify_api_key": a.Cfg.Apify.APIKey,
"ebay_client_id": a.Cfg.Ebay.ClientID,
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
"ntfy_token": "",
}
status := make(map[string]string, len(secretSettingsKeys))
for k := range secretSettingsKeys {
switch {
case strings.TrimSpace(values[k]) != "":
status[k] = "Saved in settings"
case strings.TrimSpace(configVals[k]) != "":
status[k] = "Set in config.toml"
default:
status[k] = "Not set"
}
}
return status
}
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
values, err := a.Store.GetAllSettings(r.Context())
if err != nil {
@@ -38,19 +72,20 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
return templates.SettingsData{
Page: a.page(r, "Settings", "settings"),
Values: values,
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
Page: a.page(r, "Settings", "settings"),
Values: values,
CredentialStatus: a.credentialStatus(values),
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
}, nil
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
render(w, r, templates.Settings(d))
@@ -68,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
}
for _, k := range settingsKeys {
v := strings.TrimSpace(r.PostFormValue(k))
// Secret fields are never rendered back into the form, so a blank
// submission is the normal state and means "leave unchanged" — not
// "clear". (To clear a stored credential, edit the settings table.)
if v == "" && secretSettingsKeys[k] {
continue
}
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
}
@@ -92,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
@@ -115,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
d.PasswordMsg = "Password updated"
@@ -130,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
}
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
@@ -164,7 +205,7 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
}
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
@@ -210,7 +251,7 @@ func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
}
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
// Settings-table values win over config.toml. Both paths are trimmed:

View File

@@ -13,7 +13,7 @@ import (
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
d.UserMsg = msg

View File

@@ -35,6 +35,12 @@ type Item struct {
ExcludeKeywords string
Marketplaces []string
ListingType string
// Condition and Region are eBay-only search filters. Condition is
// Veola's vocabulary ("new", "used", "refurbished", "parts"); Region is
// an ISO 3166-1 alpha-2 country code constraining item location. Both
// empty means no filter, and both are ignored for non-eBay marketplaces.
Condition string
Region string
ActorActive string
ActorSold string
ActorPriceCompare string
@@ -43,6 +49,7 @@ type Item struct {
LastPolledAt *time.Time
LastPollError string
BestPrice *float64
BestPriceCurrency string
BestPriceStore string
BestPriceURL string
BestPriceImageURL string
@@ -63,6 +70,10 @@ type Result struct {
MatchedQuery string
Alerted bool
FoundAt time.Time
// EndsAt is populated for auction-format listings (eBay auctions,
// Yahoo Auctions JP). Nil for fixed-price listings and for any source
// that doesn't report an end time.
EndsAt *time.Time
}
// SearchQueries returns the item's alias list. Splits on newline, comma, and

View File

@@ -220,6 +220,14 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
continue
}
if exists {
// Row already stored — but if this poll surfaced an end time we
// didn't have before (or the row predates the ends_at column),
// backfill it so countdowns light up for known auctions.
if r.EndsAt != nil {
if err := s.store.BackfillResultEndsAt(ctx, it.ID, r.URL, *r.EndsAt); err != nil {
slog.Error("backfill ends_at failed", "err", err)
}
}
continue
}
alerted := false
@@ -242,6 +250,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
ImageURL: r.ImageURL,
MatchedQuery: r.MatchedQuery,
Alerted: alerted,
EndsAt: r.EndsAt,
})
if err != nil {
slog.Error("insert result failed", "err", err)
@@ -257,6 +266,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
bp := best.Price
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
BestPrice: &bp,
BestPriceCurrency: best.Currency,
BestPriceStore: best.Store,
BestPriceURL: best.URL,
BestPriceImageURL: best.ImageURL,
@@ -365,6 +375,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
Store: l.Store,
ImageURL: l.ImageURL,
Source: apify.SourceActiveEbay,
EndsAt: l.EndsAt,
})
}
default:
@@ -549,6 +560,8 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
MarketplaceID: ebay.MarketplaceID(mk),
Query: query,
ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
Limit: 30,
},
})