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:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
115
internal/handlers/handlers_test.go
Normal file
115
internal/handlers/handlers_test.go
Normal 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>")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user