Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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=<api_key>` |

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
Expand Down Expand Up @@ -164,6 +168,9 @@ SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=KramerBot <noreply@yourdomain.com>

# Daily summary timezone — IANA timezone name (default: Australia/Adelaide)
SUMMARY_TIMEZONE=Australia/Adelaide
```

Generate a JWT secret:
Expand Down
10 changes: 6 additions & 4 deletions api/handlers/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
5 changes: 3 additions & 2 deletions bot/dealproc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function updatePreferences(prefs: {
ozb_super: boolean;
amz_daily: boolean;
amz_weekly: boolean;
email_summary: boolean;
}): Promise<WebUser> {
const res = await api.put<APIResponse<WebUser>>('/user/preferences', prefs);
return res.data.data!;
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CheckCircle } from 'lucide-react';

export function Toast({ message }: { message: string }) {
if (!message) return null;

return (
<div
className="fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-xl border px-4 py-3 shadow-lg"
style={{ background: '#FFFEF7', borderColor: 'var(--kb-red)' }}
>
<CheckCircle className="h-4 w-4 shrink-0" style={{ color: 'var(--kb-red)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--kb-ink)' }}>
{message}
</span>
</div>
);
}
14 changes: 14 additions & 0 deletions frontend/src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useState, useRef } from 'react';

export function useToast() {
const [toastMsg, setToastMsg] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const showToast = (msg: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
setToastMsg(msg);
timerRef.current = setTimeout(() => setToastMsg(''), 3000);
};

return { toastMsg, showToast };
}
24 changes: 21 additions & 3 deletions frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +60,7 @@ function ToggleRow({

export default function Dashboard({ user, onSignOut }: Props) {
const qc = useQueryClient();
const { toastMsg, showToast } = useToast();
const [tab, setTab] = useState<Tab>('ozb-good');
const [newKeyword, setNewKeyword] = useState('');
const [showMobilePrefs, setShowMobilePrefs] = useState(false);
Expand Down Expand Up @@ -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,
});
};

Expand Down Expand Up @@ -182,6 +193,12 @@ export default function Dashboard({ user, onSignOut }: Props) {
onChange={(v) => handlePrefToggle('amz_weekly', v)}
disabled={prefsMutation.isPending}
/>
<ToggleRow
label="📧 Daily Email Summary (8pm)"
checked={profile.email_summary ?? false}
onChange={(v) => handlePrefToggle('email_summary', v)}
disabled={prefsMutation.isPending}
/>
</div>
</div>

Expand Down Expand Up @@ -366,6 +383,7 @@ export default function Dashboard({ user, onSignOut }: Props) {
)}
</main>
</div>
<Toast message={toastMsg} />
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
11 changes: 6 additions & 5 deletions models/webuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading
Loading