Include completion photos in task completed emails
- Add EmbeddedImage struct and SendEmailWithEmbeddedImages method for inline images - Update SendTaskCompletedEmail to accept and display completion photos - Read images from disk via StorageService and embed with Content-ID references - Wire StorageService to TaskService for image access - Photos display inline in HTML email body, works across all email clients 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -32,6 +36,7 @@ type TaskService struct {
|
||||
residenceService *ResidenceService
|
||||
notificationService *NotificationService
|
||||
emailService *EmailService
|
||||
storageService *StorageService
|
||||
}
|
||||
|
||||
// NewTaskService creates a new task service
|
||||
@@ -57,6 +62,11 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
||||
s.residenceService = rs
|
||||
}
|
||||
|
||||
// SetStorageService sets the storage service (for reading completion images for emails)
|
||||
func (s *TaskService) SetStorageService(ss *StorageService) {
|
||||
s.storageService = ss
|
||||
}
|
||||
|
||||
// getSummaryForUser returns an empty summary placeholder.
|
||||
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
||||
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
||||
@@ -721,6 +731,12 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
completedByName = completion.CompletedBy.GetFullName()
|
||||
}
|
||||
|
||||
// Load completion images for email (only if storage service is available)
|
||||
var emailImages []EmbeddedImage
|
||||
if s.storageService != nil && len(completion.Images) > 0 {
|
||||
emailImages = s.loadCompletionImagesForEmail(completion.Images)
|
||||
}
|
||||
|
||||
// Notify all users
|
||||
for _, user := range users {
|
||||
isCompleter := user.ID == completion.CompletedByID
|
||||
@@ -746,24 +762,96 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
prefs, err := s.notificationService.GetPreferences(user.ID)
|
||||
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
||||
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
|
||||
go func(u models.User) {
|
||||
go func(u models.User, images []EmbeddedImage) {
|
||||
if err := s.emailService.SendTaskCompletedEmail(
|
||||
u.Email,
|
||||
u.GetFullName(),
|
||||
task.Title,
|
||||
completedByName,
|
||||
residenceName,
|
||||
images,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
||||
} else {
|
||||
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent")
|
||||
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent")
|
||||
}
|
||||
}(user)
|
||||
}(user, emailImages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding
|
||||
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
|
||||
var emailImages []EmbeddedImage
|
||||
|
||||
uploadDir := s.storageService.GetUploadDir()
|
||||
|
||||
for i, img := range images {
|
||||
// Resolve file path from stored URL
|
||||
filePath := s.resolveImageFilePath(img.ImageURL, uploadDir)
|
||||
if filePath == "" {
|
||||
log.Warn().Str("image_url", img.ImageURL).Msg("Could not resolve image file path")
|
||||
continue
|
||||
}
|
||||
|
||||
// Read file from disk
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("path", filePath).Msg("Failed to read completion image for email")
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine content type from extension
|
||||
contentType := s.getContentTypeFromPath(filePath)
|
||||
|
||||
// Create embedded image with unique Content-ID
|
||||
emailImages = append(emailImages, EmbeddedImage{
|
||||
ContentID: fmt.Sprintf("completion-image-%d", i+1),
|
||||
Filename: filepath.Base(filePath),
|
||||
ContentType: contentType,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
log.Debug().Str("path", filePath).Int("size", len(data)).Msg("Loaded completion image for email")
|
||||
}
|
||||
|
||||
return emailImages
|
||||
}
|
||||
|
||||
// resolveImageFilePath converts a stored URL to an actual file path
|
||||
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
|
||||
if storedURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle /uploads/... URLs
|
||||
if strings.HasPrefix(storedURL, "/uploads/") {
|
||||
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
|
||||
return filepath.Join(uploadDir, relativePath)
|
||||
}
|
||||
|
||||
// Handle relative paths
|
||||
return filepath.Join(uploadDir, storedURL)
|
||||
}
|
||||
|
||||
// getContentTypeFromPath returns the MIME type based on file extension
|
||||
func (s *TaskService) getContentTypeFromPath(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// GetCompletion gets a task completion by ID
|
||||
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||
|
||||
Reference in New Issue
Block a user