Skip to content

Commit c99dc1d

Browse files
authored
Merge pull request #102 from bscott/v0.5.7
v0.5.7 - Webhooks, Date Formats, and Currency Improvements
2 parents 556c552 + 307ca43 commit c99dc1d

19 files changed

+1452
-266
lines changed

AGENTS.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,90 @@ npm test
302302
- NEVER stop before pushing - that leaves work stranded locally
303303
- NEVER say "ready to push when you are" - YOU must push
304304
- If push fails, resolve and retry until it succeeds
305+
306+
<!-- BEGIN BEADS INTEGRATION -->
307+
## Issue Tracking with bd (beads)
308+
309+
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
310+
311+
### Why bd?
312+
313+
- Dependency-aware: Track blockers and relationships between issues
314+
- Git-friendly: Auto-syncs to JSONL for version control
315+
- Agent-optimized: JSON output, ready work detection, discovered-from links
316+
- Prevents duplicate tracking systems and confusion
317+
318+
### Quick Start
319+
320+
**Check for ready work:**
321+
322+
```bash
323+
bd ready --json
324+
```
325+
326+
**Create new issues:**
327+
328+
```bash
329+
bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json
330+
bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json
331+
```
332+
333+
**Claim and update:**
334+
335+
```bash
336+
bd update bd-42 --status in_progress --json
337+
bd update bd-42 --priority 1 --json
338+
```
339+
340+
**Complete work:**
341+
342+
```bash
343+
bd close bd-42 --reason "Completed" --json
344+
```
345+
346+
### Issue Types
347+
348+
- `bug` - Something broken
349+
- `feature` - New functionality
350+
- `task` - Work item (tests, docs, refactoring)
351+
- `epic` - Large feature with subtasks
352+
- `chore` - Maintenance (dependencies, tooling)
353+
354+
### Priorities
355+
356+
- `0` - Critical (security, data loss, broken builds)
357+
- `1` - High (major features, important bugs)
358+
- `2` - Medium (default, nice-to-have)
359+
- `3` - Low (polish, optimization)
360+
- `4` - Backlog (future ideas)
361+
362+
### Workflow for AI Agents
363+
364+
1. **Check ready work**: `bd ready` shows unblocked issues
365+
2. **Claim your task**: `bd update <id> --status in_progress`
366+
3. **Work on it**: Implement, test, document
367+
4. **Discover new work?** Create linked issue:
368+
- `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:<parent-id>`
369+
5. **Complete**: `bd close <id> --reason "Done"`
370+
371+
### Auto-Sync
372+
373+
bd automatically syncs with git:
374+
375+
- Exports to `.beads/issues.jsonl` after changes (5s debounce)
376+
- Imports from JSONL when newer (e.g., after `git pull`)
377+
- No manual export/import needed!
378+
379+
### Important Rules
380+
381+
- ✅ Use bd for ALL task tracking
382+
- ✅ Always use `--json` flag for programmatic use
383+
- ✅ Link discovered work with `discovered-from` dependencies
384+
- ✅ Check `bd ready` before asking "what should I work on?"
385+
- ❌ Do NOT create markdown TODO lists
386+
- ❌ Do NOT use external issue trackers
387+
- ❌ Do NOT duplicate tracking systems
388+
389+
For more details, see README.md and docs/QUICKSTART.md.
390+
391+
<!-- END BEADS INTEGRATION -->

cmd/server/main.go

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"math"
1010
"net/http"
1111
"os"
12+
"strings"
1213
"subtrackr/internal/config"
1314
"subtrackr/internal/database"
1415
"subtrackr/internal/handlers"
@@ -57,6 +58,7 @@ func main() {
5758
settingsService := service.NewSettingsService(settingsRepo)
5859
emailService := service.NewEmailService(settingsService)
5960
pushoverService := service.NewPushoverService(settingsService)
61+
webhookService := service.NewWebhookService(settingsService)
6062
logoService := service.NewLogoService()
6163

6264
// Handle CLI commands (run before starting HTTP server)
@@ -78,7 +80,7 @@ func main() {
7880
sessionService := service.NewSessionService(sessionSecret)
7981

8082
// Initialize handlers
81-
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, settingsService, currencyService, emailService, pushoverService, logoService)
83+
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, settingsService, currencyService, emailService, pushoverService, webhookService, logoService)
8284
settingsHandler := handlers.NewSettingsHandler(settingsService)
8385
categoryHandler := handlers.NewCategoryHandler(categoryService)
8486
authHandler := handlers.NewAuthHandler(settingsService, sessionService, emailService)
@@ -115,6 +117,15 @@ func main() {
115117
return 0
116118
}
117119
},
120+
"fmtDate": func(t *time.Time, format string) string {
121+
if t == nil {
122+
return ""
123+
}
124+
return t.Format(format)
125+
},
126+
"fmtTime": func(t time.Time, format string) string {
127+
return t.Format(format)
128+
},
118129
})
119130

120131
// Load HTML templates with error handling
@@ -171,10 +182,10 @@ func main() {
171182
// }
172183

173184
// Start renewal reminder scheduler
174-
go startRenewalReminderScheduler(subscriptionService, emailService, pushoverService, settingsService)
185+
go startRenewalReminderScheduler(subscriptionService, emailService, pushoverService, webhookService, settingsService)
175186

176187
// Start cancellation reminder scheduler
177-
go startCancellationReminderScheduler(subscriptionService, emailService, pushoverService, settingsService)
188+
go startCancellationReminderScheduler(subscriptionService, emailService, pushoverService, webhookService, settingsService)
178189

179190
// Start server
180191
port := os.Getenv("PORT")
@@ -216,6 +227,15 @@ func loadTemplates() *template.Template {
216227
return 0
217228
}
218229
},
230+
"fmtDate": func(t *time.Time, format string) string {
231+
if t == nil {
232+
return ""
233+
}
234+
return t.Format(format)
235+
},
236+
"fmtTime": func(t time.Time, format string) string {
237+
return t.Format(format)
238+
},
219239
})
220240

221241
// Critical templates required for basic functionality
@@ -344,6 +364,8 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
344364
api.POST("/settings/pushover", settingsHandler.SavePushoverSettings)
345365
api.POST("/settings/pushover/test", settingsHandler.TestPushoverConnection)
346366
api.GET("/settings/pushover", settingsHandler.GetPushoverConfig)
367+
api.POST("/settings/webhook", settingsHandler.SaveWebhookSettings)
368+
api.POST("/settings/webhook/test", settingsHandler.TestWebhookConnection)
347369
api.POST("/settings/notifications/:setting", settingsHandler.UpdateNotificationSetting)
348370
api.GET("/settings/notifications", settingsHandler.GetNotificationSettings)
349371
api.GET("/settings/smtp", settingsHandler.GetSMTPConfig)
@@ -356,6 +378,9 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
356378
// Currency setting
357379
api.POST("/settings/currency", settingsHandler.UpdateCurrency)
358380

381+
// Date format setting
382+
api.POST("/settings/date-format", settingsHandler.UpdateDateFormat)
383+
359384
// Dark mode setting
360385
api.POST("/settings/dark-mode", settingsHandler.ToggleDarkMode)
361386

@@ -409,11 +434,11 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
409434

410435
// startRenewalReminderScheduler starts a background goroutine that checks for
411436
// upcoming renewals and sends reminder emails and Pushover notifications daily
412-
func startRenewalReminderScheduler(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, settingsService *service.SettingsService) {
437+
func startRenewalReminderScheduler(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, settingsService *service.SettingsService) {
413438
// Run immediately on startup (after a short delay to let server initialize)
414439
go func() {
415440
time.Sleep(30 * time.Second) // Wait 30 seconds for server to fully start
416-
checkAndSendRenewalReminders(subscriptionService, emailService, pushoverService, settingsService)
441+
checkAndSendRenewalReminders(subscriptionService, emailService, pushoverService, webhookService, settingsService)
417442
}()
418443

419444
// Then run daily at midnight
@@ -430,14 +455,14 @@ func startRenewalReminderScheduler(subscriptionService *service.SubscriptionServ
430455
log.Printf("Panic in renewal reminder check: %v", r)
431456
}
432457
}()
433-
checkAndSendRenewalReminders(subscriptionService, emailService, pushoverService, settingsService)
458+
checkAndSendRenewalReminders(subscriptionService, emailService, pushoverService, webhookService, settingsService)
434459
}()
435460
}
436461
}()
437462
}
438463

439464
// checkAndSendRenewalReminders checks for subscriptions needing reminders and sends emails and Pushover notifications
440-
func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, settingsService *service.SettingsService) {
465+
func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, settingsService *service.SettingsService) {
441466
// Check if renewal reminders are enabled
442467
enabled, err := settingsService.GetBoolSetting("renewal_reminders", false)
443468
if err != nil || !enabled {
@@ -470,10 +495,11 @@ func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionServi
470495
for sub, daysUntil := range subscriptions {
471496
emailErr := emailService.SendRenewalReminder(sub, daysUntil)
472497
pushoverErr := pushoverService.SendRenewalReminder(sub, daysUntil)
498+
webhookErr := webhookService.SendRenewalReminder(sub, daysUntil)
473499

474-
// If both fail, count as failed; otherwise consider it sent
475-
if emailErr != nil && pushoverErr != nil {
476-
log.Printf("Error sending renewal reminder for subscription %s (ID: %d): email=%v, pushover=%v", sub.Name, sub.ID, emailErr, pushoverErr)
500+
// If all fail, count as failed; otherwise consider it sent
501+
if emailErr != nil && pushoverErr != nil && webhookErr != nil {
502+
log.Printf("Error sending renewal reminder for subscription %s (ID: %d): email=%v, pushover=%v, webhook=%v", sub.Name, sub.ID, emailErr, pushoverErr, webhookErr)
477503
failedCount++
478504
} else {
479505
// Mark reminder as sent for this renewal date
@@ -490,12 +516,20 @@ func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionServi
490516
log.Printf("Warning: Failed to update last reminder sent for subscription %s (ID: %d): %v", sub.Name, sub.ID, updateErr)
491517
}
492518

519+
var failed []string
493520
if emailErr != nil {
494-
log.Printf("Sent Pushover renewal reminder for subscription %s (renews in %d days) - email failed: %v", sub.Name, daysUntil, emailErr)
495-
} else if pushoverErr != nil {
496-
log.Printf("Sent email renewal reminder for subscription %s (renews in %d days) - Pushover failed: %v", sub.Name, daysUntil, pushoverErr)
521+
failed = append(failed, fmt.Sprintf("email=%v", emailErr))
522+
}
523+
if pushoverErr != nil {
524+
failed = append(failed, fmt.Sprintf("pushover=%v", pushoverErr))
525+
}
526+
if webhookErr != nil {
527+
failed = append(failed, fmt.Sprintf("webhook=%v", webhookErr))
528+
}
529+
if len(failed) > 0 {
530+
log.Printf("Sent renewal reminder for subscription %s (renews in %d days) - some channels failed: %s", sub.Name, daysUntil, strings.Join(failed, ", "))
497531
} else {
498-
log.Printf("Sent renewal reminders (email and Pushover) for subscription %s (renews in %d days)", sub.Name, daysUntil)
532+
log.Printf("Sent renewal reminders for subscription %s (renews in %d days)", sub.Name, daysUntil)
499533
}
500534
sentCount++
501535
}
@@ -506,11 +540,11 @@ func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionServi
506540

507541
// startCancellationReminderScheduler starts a background goroutine that checks for
508542
// upcoming cancellations and sends reminder emails and Pushover notifications daily
509-
func startCancellationReminderScheduler(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, settingsService *service.SettingsService) {
543+
func startCancellationReminderScheduler(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, settingsService *service.SettingsService) {
510544
// Run immediately on startup (after a short delay to let server initialize)
511545
go func() {
512546
time.Sleep(30 * time.Second) // Wait 30 seconds for server to fully start
513-
checkAndSendCancellationReminders(subscriptionService, emailService, pushoverService, settingsService)
547+
checkAndSendCancellationReminders(subscriptionService, emailService, pushoverService, webhookService, settingsService)
514548
}()
515549

516550
// Then run daily at midnight
@@ -527,14 +561,14 @@ func startCancellationReminderScheduler(subscriptionService *service.Subscriptio
527561
log.Printf("Panic in cancellation reminder check: %v", r)
528562
}
529563
}()
530-
checkAndSendCancellationReminders(subscriptionService, emailService, pushoverService, settingsService)
564+
checkAndSendCancellationReminders(subscriptionService, emailService, pushoverService, webhookService, settingsService)
531565
}()
532566
}
533567
}()
534568
}
535569

536570
// checkAndSendCancellationReminders checks for subscriptions needing cancellation reminders and sends emails and Pushover notifications
537-
func checkAndSendCancellationReminders(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, settingsService *service.SettingsService) {
571+
func checkAndSendCancellationReminders(subscriptionService *service.SubscriptionService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, settingsService *service.SettingsService) {
538572
// Check if cancellation reminders are enabled
539573
enabled, err := settingsService.GetBoolSetting("cancellation_reminders", false)
540574
if err != nil || !enabled {
@@ -567,10 +601,11 @@ func checkAndSendCancellationReminders(subscriptionService *service.Subscription
567601
for sub, daysUntil := range subscriptions {
568602
emailErr := emailService.SendCancellationReminder(sub, daysUntil)
569603
pushoverErr := pushoverService.SendCancellationReminder(sub, daysUntil)
604+
webhookErr := webhookService.SendCancellationReminder(sub, daysUntil)
570605

571-
// If both fail, count as failed; otherwise consider it sent
572-
if emailErr != nil && pushoverErr != nil {
573-
log.Printf("Error sending cancellation reminder for subscription %s (ID: %d): email=%v, pushover=%v", sub.Name, sub.ID, emailErr, pushoverErr)
606+
// If all fail, count as failed; otherwise consider it sent
607+
if emailErr != nil && pushoverErr != nil && webhookErr != nil {
608+
log.Printf("Error sending cancellation reminder for subscription %s (ID: %d): email=%v, pushover=%v, webhook=%v", sub.Name, sub.ID, emailErr, pushoverErr, webhookErr)
574609
failedCount++
575610
} else {
576611
// Mark reminder as sent for this cancellation date
@@ -587,12 +622,20 @@ func checkAndSendCancellationReminders(subscriptionService *service.Subscription
587622
log.Printf("Warning: Failed to update last cancellation reminder sent for subscription %s (ID: %d): %v", sub.Name, sub.ID, updateErr)
588623
}
589624

625+
var failed []string
590626
if emailErr != nil {
591-
log.Printf("Sent Pushover cancellation reminder for subscription %s (ends in %d days) - email failed: %v", sub.Name, daysUntil, emailErr)
592-
} else if pushoverErr != nil {
593-
log.Printf("Sent email cancellation reminder for subscription %s (ends in %d days) - Pushover failed: %v", sub.Name, daysUntil, pushoverErr)
627+
failed = append(failed, fmt.Sprintf("email=%v", emailErr))
628+
}
629+
if pushoverErr != nil {
630+
failed = append(failed, fmt.Sprintf("pushover=%v", pushoverErr))
631+
}
632+
if webhookErr != nil {
633+
failed = append(failed, fmt.Sprintf("webhook=%v", webhookErr))
634+
}
635+
if len(failed) > 0 {
636+
log.Printf("Sent cancellation reminder for subscription %s (ends in %d days) - some channels failed: %s", sub.Name, daysUntil, strings.Join(failed, ", "))
594637
} else {
595-
log.Printf("Sent cancellation reminders (email and Pushover) for subscription %s (ends in %d days)", sub.Name, daysUntil)
638+
log.Printf("Sent cancellation reminders for subscription %s (ends in %d days)", sub.Name, daysUntil)
596639
}
597640
sentCount++
598641
}

0 commit comments

Comments
 (0)