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

@@ -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);