diff --git a/README.md b/README.md index 38611b3..472d007 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ A Telegram bot — and now a full web app — to get you the latest deals from h 6. Keep track of deals already sent to avoid duplicate notifications 7. Supports scraping www.ozbargain.com.au — Regular (all deals) and Top (25+ votes in 24h) deals 8. Supports scraping www.amazon.com.au (via Camel Camel Camel RSS) — Top daily and weekly deals -9. Supports Android TV notifications (via Pipup) -10. Admin announcement broadcast +9. **Daily email summary** — opt-in digest of top OzBargain + Amazon Daily deals sent at 8pm (configurable timezone, defaults to `Australia/Adelaide`) +10. Supports Android TV notifications (via Pipup) +11. Admin announcement broadcast ## Web UI @@ -64,12 +65,13 @@ The web interface runs at `http://localhost:8989` (or the configured port). ## Email (SMTP) -Email is used for two flows: +Email is used for three flows: | Flow | Trigger | Link destination | |---|---|---| | Email verification | New account registration | `/verify-email?token=…` | | Password reset | Forgot password form | `/reset-password?token=…` | +| Daily summary | 8pm scheduler (opt-in per user) | — | ### Configuring an SMTP provider @@ -93,6 +95,8 @@ Recommended providers: | [Mailjet](https://mailjet.com) | 6,000/month | Requires verified sender domain or address | | [SendGrid](https://sendgrid.com) | 100/day | `SMTP_USER=apikey`, `SMTP_PASS=` | +Set `SUMMARY_TIMEZONE` to any valid [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g. `Australia/Sydney`, `America/New_York`). The server timezone is irrelevant — the scheduler always targets 8pm in the configured zone. + Also update `api.web_url` in `config.yaml` to your public domain so links in emails point to the right place: ```yaml @@ -164,6 +168,9 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=KramerBot + +# Daily summary timezone — IANA timezone name (default: Australia/Adelaide) +SUMMARY_TIMEZONE=Australia/Adelaide ``` Generate a JWT secret: diff --git a/api/handlers/user.go b/api/handlers/user.go index 6ec7ca0..7646771 100644 --- a/api/handlers/user.go +++ b/api/handlers/user.go @@ -29,10 +29,11 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { } type preferencesRequest struct { - OzbGood bool `json:"ozb_good"` - OzbSuper bool `json:"ozb_super"` - AmzDaily bool `json:"amz_daily"` - AmzWeekly bool `json:"amz_weekly"` + OzbGood bool `json:"ozb_good"` + OzbSuper bool `json:"ozb_super"` + AmzDaily bool `json:"amz_daily"` + AmzWeekly bool `json:"amz_weekly"` + EmailSummary bool `json:"email_summary"` } // UpdatePreferences saves the user's deal notification toggles and syncs them @@ -61,6 +62,7 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { user.OzbSuper = req.OzbSuper user.AmzDaily = req.AmzDaily user.AmzWeekly = req.AmzWeekly + user.EmailSummary = req.EmailSummary if err := h.WebUserDB.UpdateWebUser(user); err != nil { h.Logger.Error("failed to save preferences", zap.Error(err)) diff --git a/bot/dealproc.go b/bot/dealproc.go index 4d61dbf..b71fd27 100644 --- a/bot/dealproc.go +++ b/bot/dealproc.go @@ -113,8 +113,9 @@ func (k *KramerBot) processOzbargainDeals() error { } } - if user.OzbSuper && deal.DealType == int(scrapers.OZB_SUPER) && !OzbDealSent(user, &deal) { - // User is subscribed to top deals (25+ votes within 24h). + if user.OzbSuper && !user.OzbGood && deal.DealType == int(scrapers.OZB_SUPER) && !OzbDealSent(user, &deal) { + // User is subscribed to top deals only (25+ votes within 24h). + // Skip if OzbGood is also on — Regular is a superset so the deal is already sent. if err := k.SendOzbSuperDeal(user, &deal); err != nil { k.Logger.Error("Failed to send OZB top deal", zap.String("deal_id", deal.Id), diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 4f37abf..d0ad5a5 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -24,6 +24,7 @@ export async function updatePreferences(prefs: { ozb_super: boolean; amz_daily: boolean; amz_weekly: boolean; + email_summary: boolean; }): Promise { const res = await api.put>('/user/preferences', prefs); return res.data.data!; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..eafa63e --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,17 @@ +import { CheckCircle } from 'lucide-react'; + +export function Toast({ message }: { message: string }) { + if (!message) return null; + + return ( +
+ + + {message} + +
+ ); +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..9669b6f --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,14 @@ +import { useState, useRef } from 'react'; + +export function useToast() { + const [toastMsg, setToastMsg] = useState(''); + const timerRef = useRef | null>(null); + + const showToast = (msg: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + setToastMsg(msg); + timerRef.current = setTimeout(() => setToastMsg(''), 3000); + }; + + return { toastMsg, showToast }; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f200b5f..2129adb 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,6 +13,8 @@ import { import { OzbDealCard, AmazonDealCard } from '../components/DealCard'; import { TelegramLinker } from '../components/TelegramLinker'; import { Spinner } from '../components/Spinner'; +import { Toast } from '../components/Toast'; +import { useToast } from '../hooks/useToast'; import type { WebUser } from '../types'; type Tab = 'ozb-good' | 'ozb-super' | 'amz-daily' | 'amz-weekly'; @@ -58,6 +60,7 @@ function ToggleRow({ export default function Dashboard({ user, onSignOut }: Props) { const qc = useQueryClient(); + const { toastMsg, showToast } = useToast(); const [tab, setTab] = useState('ozb-good'); const [newKeyword, setNewKeyword] = useState(''); const [showMobilePrefs, setShowMobilePrefs] = useState(false); @@ -102,25 +105,33 @@ export default function Dashboard({ user, onSignOut }: Props) { onSuccess: (updated) => { setNewKeyword(''); qc.setQueryData(['keywords'], updated); + showToast('Keyword added!'); }, }); const removeKw = useMutation({ mutationFn: removeKeyword, - onSuccess: (updated) => qc.setQueryData(['keywords'], updated), + onSuccess: (updated) => { + qc.setQueryData(['keywords'], updated); + showToast('Keyword removed!'); + }, }); // Preference mutation const prefsMutation = useMutation({ mutationFn: updatePreferences, - onSuccess: (updated) => qc.setQueryData(['profile'], updated), + onSuccess: (updated) => { + qc.setQueryData(['profile'], updated); + showToast('Preferences updated!'); + }, }); - const handlePrefToggle = (key: 'ozb_good' | 'ozb_super' | 'amz_daily' | 'amz_weekly', value: boolean) => { + const handlePrefToggle = (key: 'ozb_good' | 'ozb_super' | 'amz_daily' | 'amz_weekly' | 'email_summary', value: boolean) => { prefsMutation.mutate({ ozb_good: key === 'ozb_good' ? value : profile.ozb_good ?? false, ozb_super: key === 'ozb_super' ? value : profile.ozb_super ?? false, amz_daily: key === 'amz_daily' ? value : profile.amz_daily ?? false, amz_weekly: key === 'amz_weekly' ? value : profile.amz_weekly ?? false, + email_summary: key === 'email_summary' ? value : profile.email_summary ?? false, }); }; @@ -182,6 +193,12 @@ export default function Dashboard({ user, onSignOut }: Props) { onChange={(v) => handlePrefToggle('amz_weekly', v)} disabled={prefsMutation.isPending} /> + handlePrefToggle('email_summary', v)} + disabled={prefsMutation.isPending} + /> @@ -366,6 +383,7 @@ export default function Dashboard({ user, onSignOut }: Props) { )} + ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ad3fde..bdb0b41 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -8,6 +8,7 @@ export interface WebUser { ozb_super?: boolean; amz_daily?: boolean; amz_weekly?: boolean; + email_summary?: boolean; keywords?: string[]; created_at: string; updated_at: string; diff --git a/main.go b/main.go index b07cb42..417051d 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,15 @@ import ( "context" "os" "os/signal" + "sort" + "strconv" "syscall" "time" "github.com/intothevoid/kramerbot/api" "github.com/intothevoid/kramerbot/bot" "github.com/intothevoid/kramerbot/models" + "github.com/intothevoid/kramerbot/persist" sqlite_persist "github.com/intothevoid/kramerbot/persist/sqlite" "github.com/intothevoid/kramerbot/pipup" "github.com/intothevoid/kramerbot/scrapers" @@ -105,6 +108,16 @@ func main() { } }() + if emailSvc.Enabled() && k.WebUserDB != nil { + loc, err := time.LoadLocation(config.API.SummaryTimezone) + if err != nil { + logger.Warn("Invalid SUMMARY_TIMEZONE, falling back to UTC", + zap.String("timezone", config.API.SummaryTimezone), zap.Error(err)) + loc = time.UTC + } + startDailySummaryScheduler(logger, k.WebUserDB, ozbscraper, cccscraper, emailSvc, loc) + } + // Graceful shutdown on SIGINT / SIGTERM — exits the whole process. go func() { quit := make(chan os.Signal, 1) @@ -132,3 +145,73 @@ func main() { // Start the Telegram bot (blocks until process exits). k.StartBot() } + +func startDailySummaryScheduler( + logger *zap.Logger, + webUserDB persist.WebUserDBIF, + ozbScraper *scrapers.OzBargainScraper, + cccScraper *scrapers.CamCamCamScraper, + emailSvc *util.EmailService, + loc *time.Location, +) { + go func() { + for { + now := time.Now().In(loc) + next := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, loc) + if !now.Before(next) { + next = next.Add(24 * time.Hour) + } + logger.Info("Daily summary scheduled", zap.Time("next_run", next)) + time.Sleep(time.Until(next)) + sendDailySummaries(logger, webUserDB, ozbScraper, cccScraper, emailSvc) + } + }() +} + +func sendDailySummaries( + logger *zap.Logger, + webUserDB persist.WebUserDBIF, + ozbScraper *scrapers.OzBargainScraper, + cccScraper *scrapers.CamCamCamScraper, + emailSvc *util.EmailService, +) { + users, err := webUserDB.GetAllVerifiedWebUsers() + if err != nil { + logger.Error("daily summary: failed to fetch users", zap.Error(err)) + return + } + + // Collect OZB_SUPER deals, sorted by upvotes descending. + var ozbDeals []models.OzBargainDeal + for _, d := range ozbScraper.Deals { + if d.DealType == int(scrapers.OZB_SUPER) { + ozbDeals = append(ozbDeals, d) + } + } + sort.Slice(ozbDeals, func(i, j int) bool { + vi, _ := strconv.Atoi(ozbDeals[i].Upvotes) + vj, _ := strconv.Atoi(ozbDeals[j].Upvotes) + return vi > vj + }) + + // Collect AMZ_DAILY deals. + var amzDeals []models.CamCamCamDeal + for _, d := range cccScraper.Deals { + if d.DealType == int(scrapers.AMZ_DAILY) { + amzDeals = append(amzDeals, d) + } + } + + sent := 0 + for _, user := range users { + if !user.EmailSummary { + continue + } + if err := emailSvc.SendDailySummary(user.Email, ozbDeals, amzDeals); err != nil { + logger.Error("daily summary: send failed", zap.String("email", user.Email), zap.Error(err)) + } else { + sent++ + } + } + logger.Info("daily summary sent", zap.Int("recipients", sent)) +} diff --git a/models/webuser.go b/models/webuser.go index c7ec583..6c9f1d1 100644 --- a/models/webuser.go +++ b/models/webuser.go @@ -18,11 +18,12 @@ type WebUser struct { ResetToken *string `json:"-"` ResetTokenExpires *time.Time `json:"-"` // Deal notification preferences (synced to bot's UserData when Telegram is linked). - OzbGood bool `json:"ozb_good"` - OzbSuper bool `json:"ozb_super"` - AmzDaily bool `json:"amz_daily"` - AmzWeekly bool `json:"amz_weekly"` - Keywords []string `json:"keywords"` + OzbGood bool `json:"ozb_good"` + OzbSuper bool `json:"ozb_super"` + AmzDaily bool `json:"amz_daily"` + AmzWeekly bool `json:"amz_weekly"` + EmailSummary bool `json:"email_summary"` + Keywords []string `json:"keywords"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/persist/sqlite/webuser_db.go b/persist/sqlite/webuser_db.go index 107b4e1..3b27bff 100644 --- a/persist/sqlite/webuser_db.go +++ b/persist/sqlite/webuser_db.go @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS web_users ( ozb_super INTEGER NOT NULL DEFAULT 0, amz_daily INTEGER NOT NULL DEFAULT 0, amz_weekly INTEGER NOT NULL DEFAULT 0, + email_summary INTEGER NOT NULL DEFAULT 0, keywords TEXT NOT NULL DEFAULT '[]', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -50,6 +51,8 @@ var migrateStmts = []string{ `ALTER TABLE web_users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE web_users ADD COLUMN verify_token TEXT`, `ALTER TABLE web_users ADD COLUMN verify_token_expires DATETIME`, + // Daily email summary preference. + `ALTER TABLE web_users ADD COLUMN email_summary INTEGER NOT NULL DEFAULT 0`, // Indexes — created after columns to avoid "no such column" on old schemas. `CREATE INDEX IF NOT EXISTS idx_web_users_email ON web_users(email)`, `CREATE INDEX IF NOT EXISTS idx_web_users_link_token ON web_users(link_token)`, @@ -77,7 +80,7 @@ const webUserColumns = ` telegram_chat_id, telegram_username, link_token, link_token_expires, reset_token, reset_token_expires, - ozb_good, ozb_super, amz_daily, amz_weekly, keywords, + ozb_good, ozb_super, amz_daily, amz_weekly, email_summary, keywords, created_at, updated_at` // CreateWebUser inserts a new web user record. @@ -168,6 +171,7 @@ func (udb *UserStoreDB) UpdateWebUser(user *models.WebUser) error { ozb_super = ?, amz_daily = ?, amz_weekly = ?, + email_summary = ?, keywords = ?, updated_at = ? WHERE id = ?`, @@ -176,7 +180,7 @@ func (udb *UserStoreDB) UpdateWebUser(user *models.WebUser) error { user.TelegramChatID, user.TelegramUsername, user.LinkToken, user.LinkTokenExpires, user.ResetToken, user.ResetTokenExpires, - user.OzbGood, user.OzbSuper, user.AmzDaily, user.AmzWeekly, string(kw), + user.OzbGood, user.OzbSuper, user.AmzDaily, user.AmzWeekly, user.EmailSummary, string(kw), user.UpdatedAt, user.ID, ) if err != nil { @@ -194,6 +198,39 @@ func (udb *UserStoreDB) DeleteWebUser(id string) error { return nil } +// GetAllVerifiedWebUsers returns all web users whose email has been verified. +func (udb *UserStoreDB) GetAllVerifiedWebUsers() ([]*models.WebUser, error) { + rows, err := udb.DB.Query(`SELECT ` + webUserColumns + ` FROM web_users WHERE email_verified = 1`) + if err != nil { + return nil, fmt.Errorf("failed to query verified web users: %w", err) + } + defer rows.Close() + + var users []*models.WebUser + for rows.Next() { + u := &models.WebUser{} + var kwJSON string + if err := rows.Scan( + &u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, + &u.EmailVerified, &u.VerifyToken, &u.VerifyTokenExpires, + &u.TelegramChatID, &u.TelegramUsername, + &u.LinkToken, &u.LinkTokenExpires, + &u.ResetToken, &u.ResetTokenExpires, + &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &u.EmailSummary, &kwJSON, + &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan web user: %w", err) + } + if kwJSON == "" || kwJSON == "null" { + u.Keywords = []string{} + } else { + json.Unmarshal([]byte(kwJSON), &u.Keywords) //nolint:errcheck + } + users = append(users, u) + } + return users, rows.Err() +} + // scanWebUser reads an explicit-column row into a WebUser struct. func (udb *UserStoreDB) scanWebUser(row *sql.Row) (*models.WebUser, error) { u := &models.WebUser{} @@ -204,7 +241,7 @@ func (udb *UserStoreDB) scanWebUser(row *sql.Row) (*models.WebUser, error) { &u.TelegramChatID, &u.TelegramUsername, &u.LinkToken, &u.LinkTokenExpires, &u.ResetToken, &u.ResetTokenExpires, - &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &kwJSON, + &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &u.EmailSummary, &kwJSON, &u.CreatedAt, &u.UpdatedAt, ) if err == sql.ErrNoRows { diff --git a/persist/types.go b/persist/types.go index f18464e..14dba59 100644 --- a/persist/types.go +++ b/persist/types.go @@ -28,4 +28,5 @@ type WebUserDBIF interface { GetWebUserByResetToken(token string) (*models.WebUser, error) UpdateWebUser(user *models.WebUser) error DeleteWebUser(id string) error + GetAllVerifiedWebUsers() ([]*models.WebUser, error) } diff --git a/util/config.go b/util/config.go index 01fe708..1a69a4d 100644 --- a/util/config.go +++ b/util/config.go @@ -32,11 +32,12 @@ type SMTPConfig struct { // APIConfig holds configuration for the HTTP API server. type APIConfig struct { - Enabled bool `mapstructure:"enabled"` - Port int `mapstructure:"port"` - WebURL string `mapstructure:"web_url"` - CORSOrigins []string `mapstructure:"cors_origins"` - JWTExpiryHours int `mapstructure:"jwt_expiry_hours"` + Enabled bool `mapstructure:"enabled"` + Port int `mapstructure:"port"` + WebURL string `mapstructure:"web_url"` + CORSOrigins []string `mapstructure:"cors_origins"` + JWTExpiryHours int `mapstructure:"jwt_expiry_hours"` + SummaryTimezone string `mapstructure:"summary_timezone"` } // SQLiteConfig holds SQLite database configuration @@ -243,6 +244,7 @@ func SetupConfig(confPath string, logger *zap.Logger) (*Config, error) { v.SetDefault("api.web_url", config.API.WebURL) v.SetDefault("api.cors_origins", config.API.CORSOrigins) v.SetDefault("api.jwt_expiry_hours", config.API.JWTExpiryHours) + v.SetDefault("api.summary_timezone", "Australia/Adelaide") v.SetDefault("smtp.host", config.SMTP.Host) v.SetDefault("smtp.port", config.SMTP.Port) v.SetDefault("smtp.username", config.SMTP.Username) @@ -295,6 +297,9 @@ func SetupConfig(confPath string, logger *zap.Logger) (*Config, error) { if v := os.Getenv("SMTP_FROM"); v != "" { config.SMTP.From = v } + if v := os.Getenv("SUMMARY_TIMEZONE"); v != "" { + config.API.SummaryTimezone = v + } // Ensure database directory exists dbDir := filepath.Dir(config.SQLite.DBPath) diff --git a/util/email.go b/util/email.go index 467ca08..72cb22c 100644 --- a/util/email.go +++ b/util/email.go @@ -2,10 +2,13 @@ package util import ( "fmt" + "html" "log" "net/mail" "net/smtp" "strings" + + "github.com/intothevoid/kramerbot/models" ) // EmailService sends transactional emails via SMTP (STARTTLS, port 587). @@ -127,3 +130,71 @@ func (s *EmailService) SendPasswordResetEmail(to, resetLink string) error { `, resetLink, resetLink) return s.Send(to, subject, body) } + +// SendDailySummary sends the nightly deal digest email. +// ozbDeals should be pre-filtered to OZB_SUPER type, sorted by votes descending. +// amzDeals should be pre-filtered to AMZ_DAILY type. +func (s *EmailService) SendDailySummary(to string, ozbDeals []models.OzBargainDeal, amzDeals []models.CamCamCamDeal) error { + subject := "KramerBot Daily Deal Summary 🔥" + + const limit = 10 + var ozbRows strings.Builder + for i, d := range ozbDeals { + if i >= limit { + break + } + ozbRows.WriteString(fmt.Sprintf(` + + + %s +
+ 🔺 %s votes + %s +
+ + `, html.EscapeString(d.Url), html.EscapeString(d.Title), html.EscapeString(d.Upvotes), html.EscapeString(d.PostedOn))) + } + if ozbRows.Len() == 0 { + ozbRows.WriteString(`No top deals today.`) + } + + var amzRows strings.Builder + for i, d := range amzDeals { + if i >= limit { + break + } + amzRows.WriteString(fmt.Sprintf(` + + + %s +
+ 📦 Amazon Daily +
+ + `, html.EscapeString(d.Url), html.EscapeString(d.Title))) + } + if amzRows.Len() == 0 { + amzRows.WriteString(`No Amazon daily deals today.`) + } + + body := fmt.Sprintf(` + + +
+ KramerBot — Daily Summary +
+
+

🔥 Top OzBargain Deals

+ %s
+

📦 Amazon Daily Deals

+ %s
+
+

+ KramerBot Daily Summary · Manage your preferences in the Dashboard +

+
+ +`, ozbRows.String(), amzRows.String()) + + return s.Send(to, subject, body) +}