Add webhook logging, pagination, middleware, migrations, and prod hardening
- Webhook event logging repo and subscription webhook idempotency - Pagination helper (echohelpers) with cursor/offset support - Request ID and structured logging middleware - Push client improvements (FCM HTTP v1, better error handling) - Task model version column, business constraint migrations, targeted indexes - Expanded categorization chain tests - Email service and config hardening - CI workflow updates, .gitignore additions, .env.example updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,22 +15,28 @@ import (
|
||||
|
||||
// EmailService handles sending emails
|
||||
type EmailService struct {
|
||||
cfg *config.EmailConfig
|
||||
dialer *gomail.Dialer
|
||||
cfg *config.EmailConfig
|
||||
dialer *gomail.Dialer
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewEmailService creates a new email service
|
||||
func NewEmailService(cfg *config.EmailConfig) *EmailService {
|
||||
func NewEmailService(cfg *config.EmailConfig, enabled bool) *EmailService {
|
||||
dialer := gomail.NewDialer(cfg.Host, cfg.Port, cfg.User, cfg.Password)
|
||||
|
||||
return &EmailService{
|
||||
cfg: cfg,
|
||||
dialer: dialer,
|
||||
cfg: cfg,
|
||||
dialer: dialer,
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmail sends an email
|
||||
func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error {
|
||||
if !s.enabled {
|
||||
log.Debug().Msg("Email sending disabled by feature flag")
|
||||
return nil
|
||||
}
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.cfg.From)
|
||||
m.SetHeader("To", to)
|
||||
@@ -64,6 +70,10 @@ type EmbeddedImage struct {
|
||||
|
||||
// SendEmailWithAttachment sends an email with an attachment
|
||||
func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error {
|
||||
if !s.enabled {
|
||||
log.Debug().Msg("Email sending disabled by feature flag")
|
||||
return nil
|
||||
}
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.cfg.From)
|
||||
m.SetHeader("To", to)
|
||||
@@ -94,6 +104,10 @@ func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody s
|
||||
|
||||
// SendEmailWithEmbeddedImages sends an email with inline embedded images
|
||||
func (s *EmailService) SendEmailWithEmbeddedImages(to, subject, htmlBody, textBody string, images []EmbeddedImage) error {
|
||||
if !s.enabled {
|
||||
log.Debug().Msg("Email sending disabled by feature flag")
|
||||
return nil
|
||||
}
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.cfg.From)
|
||||
m.SetHeader("To", to)
|
||||
|
||||
@@ -271,6 +271,9 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -337,7 +340,10 @@ func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*respo
|
||||
return nil, apperrors.Forbidden("error.task_access_denied")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.MarkInProgress(taskID); err != nil {
|
||||
if err := s.taskRepo.MarkInProgress(taskID, task.Version); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -377,7 +383,10 @@ func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses
|
||||
return nil, apperrors.BadRequest("error.task_already_cancelled")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Cancel(taskID); err != nil {
|
||||
if err := s.taskRepo.Cancel(taskID, task.Version); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -413,7 +422,10 @@ func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*respons
|
||||
return nil, apperrors.Forbidden("error.task_access_denied")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Uncancel(taskID); err != nil {
|
||||
if err := s.taskRepo.Uncancel(taskID, task.Version); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -453,7 +465,10 @@ func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*response
|
||||
return nil, apperrors.BadRequest("error.task_already_archived")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Archive(taskID); err != nil {
|
||||
if err := s.taskRepo.Archive(taskID, task.Version); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -489,7 +504,10 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*respon
|
||||
return nil, apperrors.Forbidden("error.task_access_denied")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Unarchive(taskID); err != nil {
|
||||
if err := s.taskRepo.Unarchive(taskID, task.Version); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -581,6 +599,9 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
task.InProgress = false
|
||||
}
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return nil, apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
|
||||
}
|
||||
|
||||
@@ -702,6 +723,9 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
|
||||
task.InProgress = false
|
||||
}
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
if errors.Is(err, repositories.ErrVersionConflict) {
|
||||
return apperrors.Conflict("error.version_conflict")
|
||||
}
|
||||
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after quick completion")
|
||||
return apperrors.Internal(err) // Return error so caller knows the update failed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user