Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend visible to everyone. - Auth: Traefik forward-auth (trust X-Authentik-* headers only from trusted_proxies CIDRs, keyed by email, role synced from admin_group), keeping local password login as break-glass. New [auth] config, CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md. - Budget: count every Apify run (apify_api_usage table) and show calls + estimated cost to all users on the dashboard, with an optional monthly-budget bar. New [budget] config + settings. - Email: Resend client for opt-in deal alerts and a weekly digest (Mon 09:00). Per-user email + toggles on Settings. New [resend] config. - Defaults: new items default to a 12-hour poll interval to cut spend. users table gains email/auth_source/email-pref columns (migrated in place). go build/vet/test green; boots and migrates cleanly.
This commit is contained in:
@@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
|
||||
for _, it := range items {
|
||||
s.register(it)
|
||||
}
|
||||
// Weekly digest: Monday 09:00 server-local. Best-effort inside
|
||||
// RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in).
|
||||
if _, err := s.cron.AddFunc("0 9 * * 1", func() {
|
||||
ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute)
|
||||
defer cancel()
|
||||
s.RunWeeklyDigest(ctx)
|
||||
}); err != nil {
|
||||
slog.Error("weekly digest schedule failed", "err", err)
|
||||
}
|
||||
s.cron.Start()
|
||||
slog.Info("scheduler started", "items", len(items))
|
||||
return nil
|
||||
@@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
s.recordError(ctx, it.ID, "no marketplaces configured for this item")
|
||||
return
|
||||
}
|
||||
apifyClient := s.apifyClient(ctx)
|
||||
var results []apify.UnifiedResult
|
||||
var errs []string
|
||||
successes := 0
|
||||
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
pcQueries = []string{""}
|
||||
}
|
||||
for _, q := range pcQueries {
|
||||
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{
|
||||
pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{
|
||||
Query: q, URL: it.URL,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
@@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
alerted = true
|
||||
alertsSent++
|
||||
}
|
||||
// Email opted-in users in addition to ntfy. Best-effort: a mail
|
||||
// failure must not block result storage or the next alert.
|
||||
s.sendDealEmails(ctx, it, r)
|
||||
}
|
||||
price := r.Price
|
||||
_, err = s.store.InsertResult(ctx, &models.Result{
|
||||
@@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
||||
return apify.New(key)
|
||||
}
|
||||
|
||||
// runApifyActor runs an Apify actor and records the call against the daily
|
||||
// usage counter that feeds the budget view. Every Apify-billed run in Veola
|
||||
// goes through here so the count stays honest.
|
||||
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
|
||||
if err := s.store.IncrementApifyUsage(ctx); err != nil {
|
||||
slog.Error("apify usage increment failed", "err", err)
|
||||
}
|
||||
return s.apifyClient(ctx).Run(ctx, actorID, input)
|
||||
}
|
||||
|
||||
// ApifyUsage reports Apify run counts and the cost model used by the budget
|
||||
// view. costPerCall and monthlyBudget come from settings, falling back to
|
||||
// config.toml.
|
||||
func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) {
|
||||
today, _ = s.store.ApifyUsageToday(ctx)
|
||||
month, _ = s.store.ApifyUsageMonth(ctx)
|
||||
costPerCall = s.cfg.Budget.CostPerApifyCall
|
||||
if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" {
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
costPerCall = f
|
||||
}
|
||||
}
|
||||
monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD
|
||||
if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" {
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
monthlyBudget = f
|
||||
}
|
||||
}
|
||||
return today, month, costPerCall, monthlyBudget
|
||||
}
|
||||
|
||||
// ebayClient returns the shared eBay client with credentials refreshed from
|
||||
// settings (falling back to config.toml). The client caches its OAuth token
|
||||
// in memory, so the same instance is reused across polls; credentials are
|
||||
@@ -382,7 +425,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
|
||||
if p.actorID == "" {
|
||||
return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
|
||||
}
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input)
|
||||
raw, err := s.runApifyActor(ctx, p.actorID, p.input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
|
||||
if actorID == "" {
|
||||
return
|
||||
}
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{
|
||||
raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{
|
||||
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user