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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ®ion,
|
||||
&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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user